|null */ public static function reportRow(mixed $item): ?array { if (!is_array($item) || !self::isId($item['id'] ?? null)) { return null; } return [ 'createdAt' => self::text($item['createdAt'] ?? '', 40), 'description' => self::text($item['description'] ?? ''), 'id' => (string) $item['id'], 'noticed' => !empty($item['noticed']), 'path' => self::pathOf((string) ($item['url'] ?? '')), 'repeats' => max(0, (int) ($item['duplicateCount'] ?? 0)), 'status' => self::status($item['status'] ?? '', self::REPORT_STATUSES), 'update' => self::update($item['updateCheck'] ?? null), 'urgent' => !empty($item['urgent']), ]; } /** * The update a report is about, when the plugin's update check opened it. * * @return array{kind:string,slug:string,name:string,from:string,to:string,outcome:string,decision:string,journalId:int,findings:array}|null */ public static function update(mixed $check): ?array { if (!is_array($check) || !is_array($check['item'] ?? null)) { return null; } $item = $check['item']; $slug = (string) ($item['slug'] ?? ''); $kind = (string) ($item['kind'] ?? ''); $outcome = (string) ($check['outcome'] ?? ''); if (!preg_match('~^[a-z0-9-]{1,200}$~', $slug) || !in_array($kind, ['plugin', 'theme'], true) || !in_array($outcome, ['held', 'rollback_failed', 'rolled_back'], true)) { return null; } $decision = (string) ($check['decision'] ?? ''); $findings = []; foreach (array_slice(is_array($check['findings'] ?? null) ? $check['findings'] : [], 0, 20) as $finding) { if (is_array($finding)) { $findings[] = [ 'after' => self::text($finding['after'] ?? '', 200), 'before' => self::text($finding['before'] ?? '', 200), 'page' => self::text($finding['page'] ?? '', 120), ]; } } return [ 'decision' => in_array($decision, ['kept', 'rolled_back', 'updated_again'], true) ? $decision : '', 'findings' => $findings, 'from' => self::text($item['from'] ?? '', 40), 'journalId' => max(0, (int) ($check['journalId'] ?? 0)), 'kind' => $kind, 'name' => self::text($item['name'] ?? '', 200), 'outcome' => $outcome, 'slug' => $slug, 'to' => self::text($item['to'] ?? '', 40), ]; } /** * @param array $item * @return array|null */ public static function report(array $item): ?array { $row = self::reportRow($item); if ($row === null) { return null; } $context = is_array($item['context'] ?? null) ? $item['context'] : []; $screenshot = is_string($context['screenshotDataUrl'] ?? null) ? $context['screenshotDataUrl'] : ''; $patches = array_values(array_filter(array_map([self::class, 'patch'], is_array($item['patches'] ?? null) ? $item['patches'] : []))); $current = null; foreach ($patches as $patch) { if ($patch['status'] !== 'REJECTED') { $current = $patch; break; } } return $row + [ 'patch' => $current ?? ($patches[0] ?? null), 'reply' => self::text($item['aiReply'] ?? ''), 'screenshot' => strlen($screenshot) <= self::MAX_SCREENSHOT_CHARS && preg_match(self::SCREENSHOT, $screenshot) === 1 ? $screenshot : '', 'url' => esc_url_raw((string) ($item['url'] ?? ''), ['http', 'https']), ]; } /** * @param mixed $item * @return array|null */ public static function patch(mixed $item): ?array { if (!is_array($item) || !self::isId($item['id'] ?? null)) { return null; } $target = in_array($item['target'] ?? '', ['dom', 'server', 'module'], true) ? (string) $item['target'] : 'dom'; $ops = is_array($item['ops'] ?? null) ? $item['ops'] : []; $tier = $item['riskTier'] ?? null; return [ 'details' => self::describe($target, $ops), 'explanation' => self::text($item['explanation'] ?? ''), 'id' => (string) $item['id'], 'kept' => !empty($item['keptAt']), 'riskTier' => in_array($tier, ['low', 'elevated'], true) ? $tier : null, 'status' => self::status($item['status'] ?? '', self::PATCH_STATUSES), ]; } /** * One plain line per change in the fix, the same wording Sarcio's own * screens use. * * @param array $ops * @return array */ public static function describe(string $target, array $ops): array { $lines = []; foreach (array_slice($ops, 0, 50) as $op) { if (!is_array($op)) { continue; } $line = match ($target) { 'module' => sprintf( /* translators: %s: the name of a part of the site's code. */ __('Replace the code in %s', 'sarcio'), self::field($op, 'id'), ), 'server' => self::describeServer($op), default => self::describeDom($op), }; if ($line !== '') { $lines[] = self::text($line, 500); } } return $lines; } /** @param array $op */ private static function describeDom(array $op): string { $selector = self::field($op, 'selector'); return match ($op['op'] ?? '') { /* translators: 1: an attribute name, 2: a page element. */ 'removeAttr' => sprintf(__('Remove %1$s from %2$s', 'sarcio'), self::field($op, 'attr'), $selector), /* translators: 1: an attribute name, 2: its new value, 3: a page element. */ 'setAttr' => sprintf(__('Set %1$s to "%2$s" on %3$s', 'sarcio'), self::field($op, 'attr'), self::field($op, 'value'), $selector), /* translators: 1: a page element, 2: style settings such as "color: red". */ 'setStyle' => sprintf(__('Style %1$s with %2$s', 'sarcio'), $selector, self::styles($op['styles'] ?? null)), /* translators: 1: a page element, 2: its new text. */ 'setText' => sprintf(__('Change the text of %1$s to "%2$s"', 'sarcio'), $selector, self::field($op, 'text')), /* translators: 1: a class name, 2: a page element. */ 'addClass' => sprintf(__('Add the class %1$s to %2$s', 'sarcio'), self::field($op, 'className'), $selector), /* translators: 1: a class name, 2: a page element. */ 'removeClass' => sprintf(__('Remove the class %1$s from %2$s', 'sarcio'), self::field($op, 'className'), $selector), /* translators: %s: a page element. */ 'hide' => sprintf(__('Hide %s', 'sarcio'), $selector), /* translators: %s: a page element. */ 'show' => sprintf(__('Show %s', 'sarcio'), $selector), default => '', }; } /** @param array $op */ private static function describeServer(array $op): string { $route = self::field($op, 'route'); return match ($op['op'] ?? '') { /* translators: 1: a form field, 2: its value, 3: a web address on the site. */ 'defaultValue' => sprintf(__('Fill in %1$s with %2$s when it is empty, on %3$s', 'sarcio'), self::field($op, 'field'), self::json($op['value'] ?? null), $route), /* translators: 1: a form field, 2: a web address on the site. */ 'stripField' => sprintf(__('Ignore %1$s on %2$s', 'sarcio'), self::field($op, 'field'), $route), /* translators: 1: a header name, 2: its value, 3: a web address on the site. */ 'setResponseHeader' => sprintf(__('Send the header %1$s: %2$s from %3$s', 'sarcio'), self::field($op, 'name'), self::field($op, 'value'), $route), /* translators: 1: the name of a check, 2: a web address on the site. */ 'skipValidation' => sprintf(__('Skip the check %1$s on %2$s', 'sarcio'), self::field($op, 'name'), $route), /* translators: 1: a setting name, 2: its value, 3: a web address on the site. */ 'overrideConfig' => sprintf(__('Use %1$s = %2$s on %3$s', 'sarcio'), self::field($op, 'key'), self::json($op['value'] ?? null), $route), /* translators: 1: a field in the answer, 2: its value, 3: a web address on the site. */ 'transformResponse' => sprintf(__('Answer with %1$s set to %2$s on %3$s', 'sarcio'), self::field($op, 'field'), self::json($op['value'] ?? null), $route), /* translators: 1: a web address on the site, 2: a status number such as 404. */ 'gateRoute' => sprintf(__('Answer %1$s with status %2$s', 'sarcio'), $route, (string) (int) ($op['status'] ?? 0)), default => '', }; } /** @param array $op */ private static function field(array $op, string $key): string { return is_scalar($op[$key] ?? null) ? self::text((string) $op[$key], 300) : ''; } private static function styles(mixed $styles): string { if (!is_array($styles)) { return ''; } $parts = []; foreach ($styles as $name => $value) { if (is_string($name) && is_scalar($value)) { $parts[] = $name . ': ' . $value; } } return self::text(implode('; ', $parts), 300); } private static function json(mixed $value): string { return self::text((string) wp_json_encode($value), 200); } private static function text(mixed $value, int $max = self::MAX_TEXT_CHARS): string { if (!is_scalar($value)) { return ''; } return mb_substr(str_replace('`', '', (string) $value), 0, $max); } /** @param array $allowed */ private static function status(mixed $value, array $allowed): string { return in_array($value, $allowed, true) ? (string) $value : ''; } private static function pathOf(string $url): string { $path = wp_parse_url($url, PHP_URL_PATH); return self::text(is_string($path) && $path !== '' ? $path : $url, 300); } public static function isId(mixed $value): bool { return is_string($value) && preg_match('~^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$~', $value) === 1; } }