$criteria * @return array */ public static function fromModels(iterable $criteria, ?string $legacyText = null): array { $models = ($criteria instanceof EloquentCollection ? $criteria : collect($criteria)) ->sort(function (AcceptanceCriterion $left, AcceptanceCriterion $right): int { return [ $left->parent_acceptance_criterion_id ?? 0, $left->position, $left->id, ] <=> [ $right->parent_acceptance_criterion_id ?? 0, $right->position, $right->id, ]; }) ->values(); if ($models->isEmpty()) { return self::normalize($legacyText); } /** @var array> $grouped */ $grouped = $models->groupBy('parent_acceptance_criterion_id')->all(); $items = []; self::flattenItems($items, $grouped, null, 0); return $items; } /** * @return array */ public static function normalize(mixed $value): array { if (is_array($value)) { return self::normalizeFromArray($value); } if (is_string($value) || $value === null) { return self::normalizeFromString($value); } return []; } /** * @param Ticket $ticket * @param array $items */ public static function syncTicket(Ticket $ticket, array $items): void { $normalized = self::normalize($items); $ticket->acceptanceCriteria()->delete(); if ($normalized === []) { return; } $parentStack = []; $positionsByParent = []; foreach ($normalized as $item) { $indent = min($item['indent'], count($parentStack)); $parentId = $indent === 0 ? null : $parentStack[$indent - 1]; $positionKey = $parentId ?? 'root'; $position = $positionsByParent[$positionKey] ?? 0; $criterion = $ticket->acceptanceCriteria()->create([ 'parent_acceptance_criterion_id' => $parentId, 'position' => $position, 'text' => $item['text'], 'is_checked' => $item['checked'], ]); $positionsByParent[$positionKey] = $position + 1; $parentStack[$indent] = $criterion->id; $parentStack = array_slice($parentStack, 0, $indent + 1); } $ticket->unsetRelation('acceptanceCriteria'); } /** * @param array $items */ public static function toText(array $items): ?string { $normalized = self::normalize($items); if ($normalized === []) { return null; } return implode("\n", array_map( static fn (array $item): string => sprintf( '%s- [%s] %s', str_repeat(' ', $item['indent']), $item['checked'] ? 'x' : ' ', $item['text'], ), $normalized, )); } /** * @param array $items * @param array> $grouped */ private static function flattenItems(array &$items, array $grouped, ?int $parentId, int $indent): void { foreach ($grouped[$parentId] ?? [] as $criterion) { $items[] = [ 'checked' => $criterion->is_checked, 'indent' => $indent, 'text' => $criterion->text, ]; self::flattenItems($items, $grouped, $criterion->id, $indent + 1); } } /** * @param array $value * @return array */ private static function normalizeFromArray(array $value): array { $rawItems = Arr::isAssoc($value) ? Arr::get($value, 'items', []) : $value; if (! is_array($rawItems)) { return []; } $items = []; foreach ($rawItems as $rawItem) { if (! is_array($rawItem)) { continue; } $text = trim((string) Arr::get($rawItem, 'text', '')); if ($text === '') { continue; } $items[] = [ 'checked' => filter_var(Arr::get($rawItem, 'checked', false), FILTER_VALIDATE_BOOLEAN), 'indent' => self::clampIndent((int) Arr::get($rawItem, 'indent', 0)), 'text' => $text, ]; } return $items; } /** * @return array */ private static function normalizeFromString(?string $value): array { $text = trim((string) $value); if ($text === '') { return []; } $lines = preg_split('/\R/u', $text); if (! is_array($lines)) { return []; } return array_values(array_filter(array_map( static fn (string $line): ?array => self::parseChecklistItem($line), $lines, ))); } /** * @return array{checked: bool, indent: int, text: string}|null */ private static function parseChecklistItem(string $line): ?array { $line = rtrim(str_replace("\t", ' ', $line)); if ($line === '') { return null; } if (! preg_match('/^(?\s*)(?.+)$/u', $line, $matches)) { return null; } $content = trim((string) Arr::get($matches, 'content', '')); if ($content === '') { return null; } $checked = false; if (preg_match('/^(?:[-*+]\s+)?\[(?[xX ])\]\s*(?.+)$/u', $content, $checkboxMatches)) { $checked = strtolower((string) Arr::get($checkboxMatches, 'state', '')) === 'x'; $content = trim((string) Arr::get($checkboxMatches, 'text', '')); } elseif (preg_match('/^[-*+]\s+(?.+)$/u', $content, $bulletMatches)) { $content = trim((string) Arr::get($bulletMatches, 'text', '')); } else { return null; } if ($content === '') { return null; } return [ 'checked' => $checked, 'indent' => self::indentLevel((string) Arr::get($matches, 'indent', '')), 'text' => $content, ]; } private static function indentLevel(string $leadingWhitespace): int { $normalizedWhitespace = str_replace("\t", ' ', $leadingWhitespace); return self::clampIndent(intdiv(strlen($normalizedWhitespace), 2)); } private static function clampIndent(int $indent): int { return max(0, min(self::MAX_INDENT_LEVEL, $indent)); } }