Add friendly redMCP query options
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace RedMCP;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use RuntimeException;
|
||||
|
||||
final class ListQueryNormalizer
|
||||
{
|
||||
private const DEFAULT_LIMIT = 25;
|
||||
private const MAX_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $arguments
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $arguments
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $arguments
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $arguments
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $arguments
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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<string,mixed> $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<string,mixed> $arguments
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function objectValue(array $arguments, string $key): array
|
||||
{
|
||||
return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user