$arguments * * @return array */ public static function listParams(array $arguments): array { $params = self::pagingParams($arguments); self::addSort($params, $arguments['sort'] ?? null); return array_merge($params, self::objectValue($arguments, 'params')); } /** * @param array $arguments * * @return array */ public static function issueFilters(array $arguments, ?DateTimeImmutable $clock = null): array { $filters = self::pagingParams($arguments); foreach ([ 'project_id', 'tracker_id', 'assigned_to_id', 'author_id', 'priority_id', 'category_id', 'query_id', ] as $key) { if (array_key_exists($key, $arguments)) { $filters[$key] = $arguments[$key]; } } if (array_key_exists('status', $arguments)) { $filters['status_id'] = self::statusValue($arguments['status']); } elseif (array_key_exists('status_id', $arguments)) { $filters['status_id'] = self::statusValue($arguments['status_id']); } self::addDateFilter($filters, 'created_on', $arguments['created'] ?? null, $clock); self::addDateFilter($filters, 'updated_on', $arguments['updated'] ?? null, $clock); self::addDateFilter($filters, 'due_date', $arguments['due'] ?? null, $clock); self::addSort($filters, $arguments['sort'] ?? null); return array_merge($filters, self::objectValue($arguments, 'filters')); } /** * @param array $arguments * * @return array */ public static function userParams(array $arguments): array { $params = self::pagingParams($arguments); foreach (['name', 'group_id'] as $key) { if (array_key_exists($key, $arguments)) { $params[$key] = $arguments[$key]; } } if (array_key_exists('status', $arguments)) { $params['status'] = self::userStatusValue($arguments['status']); } self::addSort($params, $arguments['sort'] ?? null); return array_merge($params, self::objectValue($arguments, 'params')); } /** * @param array $arguments * * @return array */ public static function searchParams(array $arguments): array { $params = self::pagingParams($arguments); foreach (['project_id', 'scope'] as $key) { if (array_key_exists($key, $arguments)) { $params[$key] = $arguments[$key]; } } foreach (['all_words', 'titles_only', 'open_issues'] as $key) { if (array_key_exists($key, $arguments)) { $params[$key] = self::booleanString($arguments[$key]); } } self::addSort($params, $arguments['sort'] ?? null); return array_merge($params, self::objectValue($arguments, 'params')); } /** * @param array $arguments * * @return array */ private static function pagingParams(array $arguments): array { $limit = self::positiveInt($arguments['limit'] ?? self::DEFAULT_LIMIT, self::DEFAULT_LIMIT); $limit = min($limit, self::MAX_LIMIT); $params = ['limit' => $limit]; if (array_key_exists('offset', $arguments)) { $params['offset'] = max(0, (int) $arguments['offset']); return $params; } if (array_key_exists('page', $arguments)) { $page = max(1, (int) $arguments['page']); $params['offset'] = ($page - 1) * $limit; } return $params; } private static function positiveInt(mixed $value, int $default): int { $value = (int) $value; return $value > 0 ? $value : $default; } /** * @param array $params * @param mixed $sort */ private static function addSort(array &$params, $sort): void { $normalized = self::sortValue($sort); if ($normalized !== null) { $params['sort'] = $normalized; } } /** * @param mixed $sort */ private static function sortValue($sort): ?string { if ($sort === null || $sort === '') { return null; } if (is_array($sort)) { $parts = []; foreach ($sort as $item) { if (!is_array($item)) { continue; } $field = trim((string) ($item['field'] ?? '')); if ($field === '') { continue; } $direction = strtolower((string) ($item['direction'] ?? 'asc')) === 'desc' ? 'desc' : 'asc'; $parts[] = $field . ':' . $direction; } return $parts === [] ? null : implode(',', $parts); } $sort = trim((string) $sort); $shortcut = strtolower(str_replace([' ', '-'], '_', $sort)); return [ 'newest' => 'updated_on:desc', 'recent' => 'updated_on:desc', 'oldest' => 'created_on:asc', 'created_newest' => 'created_on:desc', 'created_oldest' => 'created_on:asc', 'priority' => 'priority:desc,updated_on:desc', ][$shortcut] ?? $sort; } /** * @param mixed $value */ private static function statusValue($value): mixed { $status = strtolower(trim((string) $value)); return [ 'open' => 'open', 'opened' => 'open', 'active' => 'open', 'closed' => 'closed', 'all' => '*', 'any' => '*', ][$status] ?? $value; } /** * @param mixed $value */ private static function userStatusValue($value): mixed { $status = strtolower(trim((string) $value)); return [ 'active' => 1, 'registered' => 2, 'locked' => 3, 'all' => '*', 'any' => '*', ][$status] ?? $value; } /** * @param array $params * @param mixed $value */ private static function addDateFilter(array &$params, string $redmineField, $value, ?DateTimeImmutable $clock): void { if ($value === null || $value === '') { return; } $params[$redmineField] = self::dateValue($value, $clock); } /** * @param mixed $value */ private static function dateValue($value, ?DateTimeImmutable $clock): string { $clock = $clock ?? new DateTimeImmutable('now', new DateTimeZone('UTC')); $clock = $clock->setTimezone(new DateTimeZone('UTC')); if (is_array($value)) { $from = self::datePart($value['from'] ?? null, $clock); $to = self::datePart($value['to'] ?? null, $clock); return self::rangeValue($from, $to); } $text = trim((string) $value); if ($text === '') { throw new RuntimeException('Date filter cannot be empty.'); } if (preg_match('/^(.+)\.\.(.+)$/', $text, $matches)) { return self::rangeValue(self::datePart($matches[1], $clock), self::datePart($matches[2], $clock)); } if (preg_match('/^(since|after)\s+(.+)$/i', $text, $matches)) { return '>=' . self::datePart($matches[2], $clock); } if (preg_match('/^(before|until)\s+(.+)$/i', $text, $matches)) { return '<=' . self::datePart($matches[2], $clock); } $preset = strtolower(str_replace(['-', ' '], '_', $text)); if ($preset === 'today') { return $clock->format('Y-m-d'); } if ($preset === 'yesterday') { return $clock->modify('-1 day')->format('Y-m-d'); } if (preg_match('/^last_(\d+)_days$/', $preset, $matches)) { $days = max(1, (int) $matches[1]); return self::rangeValue($clock->modify('-' . ($days - 1) . ' days')->format('Y-m-d'), $clock->format('Y-m-d')); } if ($preset === 'this_week') { return self::rangeValue($clock->modify('monday this week')->format('Y-m-d'), $clock->format('Y-m-d')); } if ($preset === 'last_week') { return self::rangeValue( $clock->modify('monday last week')->format('Y-m-d'), $clock->modify('sunday last week')->format('Y-m-d') ); } if ($preset === 'this_month') { return self::rangeValue($clock->modify('first day of this month')->format('Y-m-d'), $clock->format('Y-m-d')); } if ($preset === 'last_month') { return self::rangeValue( $clock->modify('first day of last month')->format('Y-m-d'), $clock->modify('last day of last month')->format('Y-m-d') ); } return self::datePart($text, $clock); } private static function rangeValue(?string $from, ?string $to): string { if ($from !== null && $to !== null) { return '><' . $from . '|' . $to; } if ($from !== null) { return '>=' . $from; } if ($to !== null) { return '<=' . $to; } throw new RuntimeException('Date range requires from, to, or both.'); } /** * @param mixed $value */ private static function datePart($value, DateTimeImmutable $clock): ?string { if ($value === null || trim((string) $value) === '') { return null; } $text = trim((string) $value); $timestamp = strtotime($text, $clock->getTimestamp()); if ($timestamp === false) { throw new RuntimeException('Could not parse date filter: ' . $text); } return gmdate('Y-m-d', $timestamp); } /** * @param mixed $value */ private static function booleanString($value): string { if (is_string($value)) { $normalized = strtolower(trim($value)); if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { return '1'; } if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { return '0'; } } return $value ? '1' : '0'; } /** * @param array $arguments * * @return array */ private static function objectValue(array $arguments, string $key): array { return is_array($arguments[$key] ?? null) ? $arguments[$key] : []; } }