253 lines
7.3 KiB
PHP
253 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace App\Actions\Tickets\Support;
|
|
|
|
use App\Models\AcceptanceCriterion;
|
|
use App\Models\Ticket;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Support\Arr;
|
|
|
|
class AcceptanceCriteriaChecklist
|
|
{
|
|
public const MAX_INDENT_LEVEL = 8;
|
|
|
|
/**
|
|
* @param iterable<AcceptanceCriterion> $criteria
|
|
* @return array<int, array{checked: bool, indent: int, text: string}>
|
|
*/
|
|
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<int|string, iterable<AcceptanceCriterion>> $grouped */
|
|
$grouped = $models->groupBy('parent_acceptance_criterion_id')->all();
|
|
$items = [];
|
|
|
|
self::flattenItems($items, $grouped, null, 0);
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{checked: bool, indent: int, text: string}>
|
|
*/
|
|
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<int, array{checked: bool, indent: int, text: string}> $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<int, array{checked: bool, indent: int, text: string}> $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<int, array{checked: bool, indent: int, text: string}> $items
|
|
* @param array<int|string, iterable<AcceptanceCriterion>> $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<int|string, mixed> $value
|
|
* @return array<int, array{checked: bool, indent: int, text: string}>
|
|
*/
|
|
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<int, array{checked: bool, indent: int, text: string}>
|
|
*/
|
|
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('/^(?<indent>\s*)(?<content>.+)$/u', $line, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
$content = trim((string) Arr::get($matches, 'content', ''));
|
|
|
|
if ($content === '') {
|
|
return null;
|
|
}
|
|
|
|
$checked = false;
|
|
|
|
if (preg_match('/^(?:[-*+]\s+)?\[(?<state>[xX ])\]\s*(?<text>.+)$/u', $content, $checkboxMatches)) {
|
|
$checked = strtolower((string) Arr::get($checkboxMatches, 'state', '')) === 'x';
|
|
$content = trim((string) Arr::get($checkboxMatches, 'text', ''));
|
|
} elseif (preg_match('/^[-*+]\s+(?<text>.+)$/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));
|
|
}
|
|
}
|