feat(Docker): Upgrade to PHP 8.5.0RC3 with native ext-uri support
BREAKING CHANGE: Requires PHP 8.5.0RC3 Changes: - Update Docker base image from php:8.4-fpm to php:8.5.0RC3-fpm - Enable ext-uri for native WHATWG URL parsing support - Update composer.json PHP requirement from ^8.4 to ^8.5 - Add ext-uri as required extension in composer.json - Move URL classes from Url.php85/ to Url/ directory (now compatible) - Remove temporary PHP 8.4 compatibility workarounds Benefits: - Native URL parsing with Uri\WhatWg\Url class - Better performance for URL operations - Future-proof with latest PHP features - Eliminates PHP version compatibility issues
This commit is contained in:
@@ -28,7 +28,7 @@ final readonly class MLDashboardAdminController
|
||||
#[Route(path: '/admin/ml/dashboard', method: Method::GET, name: AdminRoutes::ML_DASHBOARD)]
|
||||
public function dashboard(HttpRequest $request): ViewResult
|
||||
{
|
||||
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 24);
|
||||
$timeWindowHours = $request->query->getInt('timeWindow', 24);
|
||||
$timeWindow = Duration::fromHours($timeWindowHours);
|
||||
|
||||
// Get all models
|
||||
@@ -115,6 +115,12 @@ final readonly class MLDashboardAdminController
|
||||
$byType[$typeName] = ($byType[$typeName] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Fetch confusion matrices
|
||||
$confusionMatrices = $this->getConfusionMatrices($allModels, $timeWindow);
|
||||
|
||||
// Fetch registry summary
|
||||
$registrySummary = $this->getRegistrySummary($allModels);
|
||||
|
||||
$data = [
|
||||
'title' => 'ML Model Dashboard',
|
||||
'page_title' => 'Machine Learning Model Dashboard',
|
||||
@@ -143,6 +149,17 @@ final readonly class MLDashboardAdminController
|
||||
'has_alerts' => count($degradationAlerts) > 0,
|
||||
'alert_count' => count($degradationAlerts),
|
||||
|
||||
// Confusion matrices
|
||||
'confusion_matrices' => $confusionMatrices,
|
||||
'has_confusion_matrices' => count($confusionMatrices) > 0,
|
||||
|
||||
// Registry summary
|
||||
'registry_total_versions' => $registrySummary['total_versions'],
|
||||
'registry_production_count' => $registrySummary['production_count'],
|
||||
'registry_development_count' => $registrySummary['development_count'],
|
||||
'registry_models' => $registrySummary['models'],
|
||||
'has_registry_summary' => count($registrySummary['models']) > 0,
|
||||
|
||||
// Links
|
||||
'api_dashboard_url' => '/api/ml/dashboard',
|
||||
'api_health_url' => '/api/ml/dashboard/health',
|
||||
@@ -172,4 +189,109 @@ final readonly class MLDashboardAdminController
|
||||
|
||||
return $allModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confusion matrices for classification models
|
||||
*/
|
||||
private function getConfusionMatrices(array $allModels, Duration $timeWindow): array
|
||||
{
|
||||
$matrices = [];
|
||||
|
||||
foreach ($allModels as $metadata) {
|
||||
$metrics = $this->performanceMonitor->getCurrentMetrics(
|
||||
$metadata->modelName,
|
||||
$metadata->version,
|
||||
$timeWindow
|
||||
);
|
||||
|
||||
if (isset($metrics['confusion_matrix'])) {
|
||||
$cm = $metrics['confusion_matrix'];
|
||||
$total = $metrics['total_predictions'];
|
||||
|
||||
$fpRate = $total > 0 ? $cm['false_positive'] / $total : 0.0;
|
||||
$fnRate = $total > 0 ? $cm['false_negative'] / $total : 0.0;
|
||||
|
||||
$matrices[] = [
|
||||
'model_name' => $metadata->modelName,
|
||||
'version' => $metadata->version->toString(),
|
||||
'type' => $metadata->modelType->value,
|
||||
'true_positives' => number_format($cm['true_positive']),
|
||||
'true_negatives' => number_format($cm['true_negative']),
|
||||
'false_positives' => number_format($cm['false_positive']),
|
||||
'false_negatives' => number_format($cm['false_negative']),
|
||||
'fp_rate' => round($fpRate, 4),
|
||||
'fn_rate' => round($fnRate, 4),
|
||||
'fp_rate_percent' => round($fpRate * 100, 2),
|
||||
'fn_rate_percent' => round($fnRate * 100, 2),
|
||||
'fp_rate_badge' => $fpRate > 0.1 ? 'warning' : 'success',
|
||||
'fn_rate_badge' => $fnRate > 0.1 ? 'warning' : 'success',
|
||||
'total_predictions' => $total,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $matrices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model registry summary statistics
|
||||
*/
|
||||
private function getRegistrySummary(array $allModels): array
|
||||
{
|
||||
// Group by model name
|
||||
$modelGroups = [];
|
||||
$productionCount = 0;
|
||||
$developmentCount = 0;
|
||||
|
||||
foreach ($allModels as $metadata) {
|
||||
$modelName = $metadata->modelName;
|
||||
if (!isset($modelGroups[$modelName])) {
|
||||
$modelGroups[$modelName] = [
|
||||
'model_name' => $modelName,
|
||||
'type' => $metadata->modelType->value,
|
||||
'versions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$modelGroups[$modelName]['versions'][] = [
|
||||
'version' => $metadata->version->toString(),
|
||||
'environment' => $metadata->environment,
|
||||
];
|
||||
|
||||
// Count environments
|
||||
if ($metadata->environment === 'production') {
|
||||
$productionCount++;
|
||||
} elseif ($metadata->environment === 'development') {
|
||||
$developmentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary per model
|
||||
$modelsSummary = [];
|
||||
foreach ($modelGroups as $modelName => $group) {
|
||||
// Sort versions
|
||||
$versions = array_column($group['versions'], 'version');
|
||||
usort($versions, 'version_compare');
|
||||
|
||||
// Get latest environment
|
||||
$latestVersion = end($versions);
|
||||
$latestVersionData = array_filter($group['versions'], fn($v) => $v['version'] === $latestVersion);
|
||||
$latestEnv = !empty($latestVersionData) ? reset($latestVersionData)['environment'] : 'unknown';
|
||||
|
||||
$modelsSummary[] = [
|
||||
'model_name' => $modelName,
|
||||
'type' => $group['type'],
|
||||
'version_count' => count($versions),
|
||||
'latest_version' => $latestVersion,
|
||||
'environment' => $latestEnv,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_versions' => count($allModels),
|
||||
'production_count' => $productionCount,
|
||||
'development_count' => $developmentCount,
|
||||
'models' => $modelsSummary,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Notifications;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
use App\Framework\Http\Responses\JsonResponse;
|
||||
use App\Framework\Notification\Storage\NotificationRepository;
|
||||
use App\Framework\Notification\ValueObjects\NotificationId;
|
||||
use App\Framework\Meta\MetaData;
|
||||
|
||||
/**
|
||||
* Admin Notifications Controller
|
||||
*
|
||||
* Displays and manages system notifications for administrators
|
||||
*/
|
||||
final readonly class NotificationsAdminController
|
||||
{
|
||||
public function __construct(
|
||||
private NotificationRepository $notificationRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display notifications list
|
||||
*/
|
||||
#[Route(path: '/admin/notifications', method: Method::GET)]
|
||||
public function index(HttpRequest $request): ViewResult
|
||||
{
|
||||
// For now, use 'admin' as recipient ID
|
||||
// TODO: Replace with actual authenticated admin user ID
|
||||
$adminUserId = 'admin';
|
||||
|
||||
// Get pagination parameters
|
||||
$page = $request->query->getInt('page', 1);
|
||||
$perPage = 20;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Fetch notifications
|
||||
$notifications = $this->notificationRepository->findByUser(
|
||||
$adminUserId,
|
||||
limit: $perPage,
|
||||
offset: $offset
|
||||
);
|
||||
|
||||
// Get unread count
|
||||
$unreadCount = $this->notificationRepository->countUnreadByUser($adminUserId);
|
||||
|
||||
// Transform notifications for template
|
||||
$notificationsList = array_map(function ($notification) {
|
||||
return [
|
||||
'id' => $notification->id->toString(),
|
||||
'type' => $notification->type->toString(),
|
||||
'title' => $notification->title,
|
||||
'body' => $notification->body,
|
||||
'priority' => $notification->priority->value,
|
||||
'status' => $notification->status->value,
|
||||
'is_read' => $notification->status->value === 'read',
|
||||
'created_at' => $notification->createdAt->format('Y-m-d H:i:s'),
|
||||
'created_at_human' => $this->getHumanReadableTime($notification->createdAt->toDateTime()),
|
||||
'action_url' => $notification->actionUrl,
|
||||
'action_label' => $notification->actionLabel ?? 'View Details',
|
||||
'icon' => $this->getNotificationIcon($notification->type->toString()),
|
||||
'badge_class' => $this->getPriorityBadgeClass($notification->priority->value),
|
||||
];
|
||||
}, $notifications);
|
||||
|
||||
$data = [
|
||||
'notifications' => $notificationsList,
|
||||
'unread_count' => $unreadCount,
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'has_more' => count($notifications) === $perPage,
|
||||
'has_notifications' => count($notifications) > 0,
|
||||
'has_unread' => $unreadCount > 0, // Boolean flag for if attribute
|
||||
'show_pagination' => count($notifications) === $perPage || $page > 1, // Boolean flag
|
||||
];
|
||||
|
||||
return new ViewResult(
|
||||
template: 'notification-index',
|
||||
metaData: new MetaData(
|
||||
title: 'Notifications - Admin',
|
||||
description: 'System notifications for administrators'
|
||||
),
|
||||
data : $data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
#[Route(path: '/admin/notifications/{id}/read', method: Method::POST)]
|
||||
public function markAsRead(HttpRequest $request, string $id): JsonResponse
|
||||
{
|
||||
$notificationId = NotificationId::fromString($id);
|
||||
|
||||
$success = $this->notificationRepository->markAsRead($notificationId);
|
||||
|
||||
if (!$success) {
|
||||
return new JsonResponse(
|
||||
body: ['error' => 'Notification not found'],
|
||||
status: Status::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
// Get updated unread count
|
||||
$unreadCount = $this->notificationRepository->countUnreadByUser('admin');
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'unread_count' => $unreadCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
#[Route(path: '/admin/notifications/read-all', method: Method::POST)]
|
||||
public function markAllAsRead(HttpRequest $request): JsonResponse
|
||||
{
|
||||
$count = $this->notificationRepository->markAllAsReadForUser('admin');
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'marked_count' => $count,
|
||||
'unread_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for badge
|
||||
*/
|
||||
#[Route(path: '/admin/notifications/unread-count', method: Method::GET)]
|
||||
public function getUnreadCount(HttpRequest $request): JsonResponse
|
||||
{
|
||||
$unreadCount = $this->notificationRepository->countUnreadByUser('admin');
|
||||
|
||||
return new JsonResponse([
|
||||
'unread_count' => $unreadCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable time difference
|
||||
*/
|
||||
private function getHumanReadableTime(\DateTimeInterface $timestamp): string
|
||||
{
|
||||
$now = new \DateTime();
|
||||
$diff = $now->diff($timestamp);
|
||||
|
||||
if ($diff->y > 0) {
|
||||
return $diff->y . ' year' . ($diff->y > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
if ($diff->m > 0) {
|
||||
return $diff->m . ' month' . ($diff->m > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
if ($diff->d > 0) {
|
||||
return $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
if ($diff->h > 0) {
|
||||
return $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
if ($diff->i > 0) {
|
||||
return $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for notification type
|
||||
*/
|
||||
private function getNotificationIcon(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'ml_performance_degradation' => '⚠️',
|
||||
'ml_model_deployed' => '🚀',
|
||||
'ml_training_complete' => '✅',
|
||||
'system_alert' => '🚨',
|
||||
'security_alert' => '🔒',
|
||||
'info' => 'ℹ️',
|
||||
default => '📢',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge class for priority
|
||||
*/
|
||||
private function getPriorityBadgeClass(string $priority): string
|
||||
{
|
||||
return match ($priority) {
|
||||
'urgent' => 'admin-badge--danger',
|
||||
'high' => 'admin-badge--warning',
|
||||
'normal' => 'admin-badge--info',
|
||||
'low' => 'admin-badge--secondary',
|
||||
default => 'admin-badge--info',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{meta.title}</title>
|
||||
<meta name="description" content="{meta.description}">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<style>
|
||||
.notification-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.notification-header h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-mark-all {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-mark-all:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
border-left: 4px solid var(--color-primary);
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.notification-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.notification-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-badge--danger {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
}
|
||||
|
||||
.admin-badge--warning {
|
||||
background: #ffc;
|
||||
color: #c93;
|
||||
}
|
||||
|
||||
.admin-badge--info {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.admin-badge--secondary {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.notification-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.notification-action:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn-mark-read {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-mark-read:hover {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pagination-link {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pagination-link:hover {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar-header">
|
||||
<h2>Admin Panel</h2>
|
||||
</div>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin/dashboard" class="admin-nav-link">
|
||||
📊 Dashboard
|
||||
</a>
|
||||
<a href="/admin/ml/dashboard" class="admin-nav-link">
|
||||
🤖 ML Dashboard
|
||||
</a>
|
||||
<a href="/admin/notifications" class="admin-nav-link admin-nav-link--active">
|
||||
🔔 Notifications
|
||||
<span class="unread-badge" if="$unread_count > 0">{{ $unread_count }}</span>
|
||||
</a>
|
||||
<a href="/admin/users" class="admin-nav-link">
|
||||
👥 Users
|
||||
</a>
|
||||
<a href="/admin/settings" class="admin-nav-link">
|
||||
⚙️ Settings
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
<div class="notification-container">
|
||||
<div class="notification-header">
|
||||
<h1>Notifications</h1>
|
||||
<span class="unread-badge" if="$unread_count > 0">{{ $unread_count }} unread</span>
|
||||
</div>
|
||||
|
||||
<div class="notification-actions" if="$unread_count > 0">
|
||||
<button class="btn-mark-all" onclick="markAllAsRead()">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="notification-list" if="$has_notifications">
|
||||
<div foreach="$notifications as $notification" class="notification-item" data-id="{{ $notification['id'] }}">
|
||||
<div class="notification-item-header">
|
||||
<div class="notification-title-wrapper">
|
||||
<span class="notification-icon">{{ $notification['icon'] }}</span>
|
||||
<h3 class="notification-title">{{ $notification['title'] }}</h3>
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span class="admin-badge {{ $notification['badge_class'] }}">{{ $notification['priority'] }}</span>
|
||||
<span class="notification-time">{{ $notification['created_at_human'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-body">
|
||||
{{ $notification['body'] }}
|
||||
</div>
|
||||
|
||||
<div class="notification-footer">
|
||||
<div>
|
||||
<a href="{{ $notification['action_url'] }}" class="notification-action" if="$notification['action_url']">
|
||||
{{ $notification['action_label'] }} →
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
class="btn-mark-read"
|
||||
onclick="markAsRead('{{ $notification['id'] }}')"
|
||||
>
|
||||
Mark as read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination" if="$has_more || $current_page > 1">
|
||||
<a href="/admin/notifications?page={{ $current_page - 1 }}" class="pagination-link" if="$current_page > 1">
|
||||
← Previous
|
||||
</a>
|
||||
|
||||
<span class="pagination-info">Page {{ $current_page }}</span>
|
||||
|
||||
<a href="/admin/notifications?page={{ $current_page + 1 }}" class="pagination-link" if="$has_more">
|
||||
Next →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" if="!$has_notifications">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<h2 class="empty-state-title">No notifications</h2>
|
||||
<p>You're all caught up! No notifications to display.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function markAsRead(notificationId) {
|
||||
try {
|
||||
const response = await fetch(`/admin/notifications/${notificationId}/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update UI
|
||||
const item = document.querySelector(`[data-id="${notificationId}"]`);
|
||||
if (item) {
|
||||
item.classList.remove('unread');
|
||||
|
||||
const button = item.querySelector('.btn-mark-read');
|
||||
if (button) {
|
||||
button.textContent = '✓ Read';
|
||||
button.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update unread count badge
|
||||
updateUnreadCount(data.unread_count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error);
|
||||
alert('Failed to mark notification as read. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
if (!confirm('Mark all notifications as read?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/notifications/read-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Reload page to show updated state
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error);
|
||||
alert('Failed to mark all notifications as read. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function updateUnreadCount(count) {
|
||||
const badges = document.querySelectorAll('.unread-badge');
|
||||
badges.forEach(badge => {
|
||||
if (count === 0) {
|
||||
badge.style.display = 'none';
|
||||
} else {
|
||||
badge.textContent = count;
|
||||
badge.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Poll for new notifications every 30 seconds
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/admin/notifications/unread-count');
|
||||
const data = await response.json();
|
||||
|
||||
const currentBadge = document.querySelector('.unread-badge');
|
||||
const currentCount = currentBadge ? parseInt(currentBadge.textContent) || 0 : 0;
|
||||
|
||||
if (data.unread_count !== currentCount) {
|
||||
updateUnreadCount(data.unread_count);
|
||||
|
||||
if (data.unread_count > currentCount) {
|
||||
console.log('New notifications received');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling for notifications:', error);
|
||||
}
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -228,6 +228,123 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confusion Matrices Section -->
|
||||
<div class="admin-card" if="{{ $has_confusion_matrices }}">
|
||||
<div class="admin-card__header">
|
||||
<h3 class="admin-card__title">Classification Performance (Confusion Matrices)</h3>
|
||||
</div>
|
||||
<div class="admin-card__content">
|
||||
<div class="admin-grid admin-grid--2-col">
|
||||
<div foreach="$confusion_matrices as $matrix" class="confusion-matrix-card">
|
||||
<h4 class="confusion-matrix-card__title">{{ $matrix['model_name'] }} v{{ $matrix['version'] }}</h4>
|
||||
|
||||
<div class="confusion-matrix">
|
||||
<div class="confusion-matrix__grid">
|
||||
<!-- True Positive -->
|
||||
<div class="confusion-matrix__cell confusion-matrix__cell--tp">
|
||||
<div class="confusion-matrix__cell-label">True Positive</div>
|
||||
<div class="confusion-matrix__cell-value">{{ $matrix['true_positives'] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- False Positive -->
|
||||
<div class="confusion-matrix__cell confusion-matrix__cell--fp">
|
||||
<div class="confusion-matrix__cell-label">False Positive</div>
|
||||
<div class="confusion-matrix__cell-value">{{ $matrix['false_positives'] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- False Negative -->
|
||||
<div class="confusion-matrix__cell confusion-matrix__cell--fn">
|
||||
<div class="confusion-matrix__cell-label">False Negative</div>
|
||||
<div class="confusion-matrix__cell-value">{{ $matrix['false_negatives'] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- True Negative -->
|
||||
<div class="confusion-matrix__cell confusion-matrix__cell--tn">
|
||||
<div class="confusion-matrix__cell-label">True Negative</div>
|
||||
<div class="confusion-matrix__cell-value">{{ $matrix['true_negatives'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="confusion-matrix__rates">
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">False Positive Rate</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<span class="admin-badge admin-badge--{{ $matrix['fp_rate_badge'] }}">
|
||||
{{ $matrix['fp_rate_percent'] }}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">False Negative Rate</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<span class="admin-badge admin-badge--{{ $matrix['fn_rate_badge'] }}">
|
||||
{{ $matrix['fn_rate_percent'] }}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Registry Summary -->
|
||||
<div class="admin-card" if="{{ $has_registry_summary }}">
|
||||
<div class="admin-card__header">
|
||||
<h3 class="admin-card__title">Model Registry Summary</h3>
|
||||
</div>
|
||||
<div class="admin-card__content">
|
||||
<div class="admin-grid admin-grid--3-col">
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Total Versions</span>
|
||||
<span class="admin-stat-item__value">{{ $registry_total_versions }}</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Production Models</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<span class="admin-badge admin-badge--success">{{ $registry_production_count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Development Models</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<span class="admin-badge admin-badge--info">{{ $registry_development_count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-container">
|
||||
<table class="admin-table admin-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model Name</th>
|
||||
<th>Total Versions</th>
|
||||
<th>Type</th>
|
||||
<th>Latest Version</th>
|
||||
<th>Environment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr foreach="$registry_models as $regModel">
|
||||
<td><strong>{{ $regModel['model_name'] }}</strong></td>
|
||||
<td>{{ $regModel['version_count'] }}</td>
|
||||
<td>
|
||||
<span class="admin-badge admin-badge--info">{{ $regModel['type'] }}</span>
|
||||
</td>
|
||||
<td><code>{{ $regModel['latest_version'] }}</code></td>
|
||||
<td>
|
||||
<span class="admin-badge admin-badge--{{ $regModel['environment'] === 'production' ? 'success' : 'info' }}">
|
||||
{{ $regModel['environment'] }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Information Card -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card__header">
|
||||
@@ -247,6 +364,18 @@
|
||||
<code>GET {{ $api_health_url }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Confusion Matrices</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<code>GET /api/ml/dashboard/confusion-matrices</code>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Registry Summary</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<code>GET /api/ml/dashboard/registry-summary</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,8 +350,8 @@ final readonly class MLABTestingController
|
||||
)]
|
||||
public function calculateSampleSize(HttpRequest $request): JsonResult
|
||||
{
|
||||
$confidenceLevel = (float) ($request->queryParameters['confidence_level'] ?? 0.95);
|
||||
$marginOfError = (float) ($request->queryParameters['margin_of_error'] ?? 0.05);
|
||||
$confidenceLevel = $request->query->getFloat('confidence_level', 0.95);
|
||||
$marginOfError = $request->query->getFloat('margin_of_error', 0.05);
|
||||
|
||||
// Validate parameters
|
||||
if ($confidenceLevel < 0.5 || $confidenceLevel > 0.99) {
|
||||
|
||||
@@ -91,7 +91,7 @@ final readonly class MLDashboardController
|
||||
)]
|
||||
public function getDashboardData(HttpRequest $request): JsonResult
|
||||
{
|
||||
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 24);
|
||||
$timeWindowHours = $request->query->getInt('timeWindow', 24);
|
||||
$timeWindow = Duration::fromHours($timeWindowHours);
|
||||
|
||||
// Get all models
|
||||
@@ -280,7 +280,7 @@ final readonly class MLDashboardController
|
||||
)]
|
||||
public function getAlerts(HttpRequest $request): JsonResult
|
||||
{
|
||||
$severityFilter = $request->queryParameters['severity'] ?? null;
|
||||
$severityFilter = $request->query->get('severity');
|
||||
$allModels = $this->getAllModels();
|
||||
$timeWindow = Duration::fromHours(1);
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ final readonly class MLModelsController
|
||||
)]
|
||||
public function listModels(HttpRequest $request): JsonResult
|
||||
{
|
||||
$typeFilter = $request->queryParameters['type'] ?? null;
|
||||
$typeFilter = $request->query->get('type');
|
||||
|
||||
// Get all model names
|
||||
$modelNames = $this->registry->getAllModelNames();
|
||||
@@ -161,7 +161,7 @@ final readonly class MLModelsController
|
||||
)]
|
||||
public function getModel(string $modelName, HttpRequest $request): JsonResult
|
||||
{
|
||||
$versionString = $request->queryParameters['version'] ?? null;
|
||||
$versionString = $request->query->get('version');
|
||||
|
||||
try {
|
||||
if ($versionString !== null) {
|
||||
@@ -253,8 +253,8 @@ final readonly class MLModelsController
|
||||
)]
|
||||
public function getMetrics(string $modelName, HttpRequest $request): JsonResult
|
||||
{
|
||||
$versionString = $request->queryParameters['version'] ?? null;
|
||||
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 1);
|
||||
$versionString = $request->query->get('version');
|
||||
$timeWindowHours = $request->query->getInt('timeWindow', 1);
|
||||
|
||||
try {
|
||||
if ($versionString !== null) {
|
||||
@@ -439,7 +439,7 @@ final readonly class MLModelsController
|
||||
)]
|
||||
public function unregisterModel(string $modelName, HttpRequest $request): JsonResult
|
||||
{
|
||||
$versionString = $request->queryParameters['version'] ?? null;
|
||||
$versionString = $request->query->get('version');
|
||||
|
||||
if ($versionString === null) {
|
||||
return new JsonResult([
|
||||
|
||||
@@ -37,9 +37,9 @@ interface LiveComponentState extends SerializableState
|
||||
* Create State VO from array data (from client or storage)
|
||||
*
|
||||
* @param array $data Raw state data
|
||||
* @return static Hydrated state object
|
||||
* @return self Hydrated state object
|
||||
*/
|
||||
public static function fromArray(array $data): static;
|
||||
public static function fromArray(array $data): self;
|
||||
|
||||
/**
|
||||
* Convert State VO to array for serialization
|
||||
|
||||
@@ -12,6 +12,7 @@ enum HashAlgorithm: string
|
||||
case SHA512 = 'sha512';
|
||||
case SHA3_256 = 'sha3-256';
|
||||
case SHA3_512 = 'sha3-512';
|
||||
case XXHASH3 = 'xxh3';
|
||||
case XXHASH64 = 'xxh64';
|
||||
|
||||
public function isSecure(): bool
|
||||
@@ -29,6 +30,7 @@ enum HashAlgorithm: string
|
||||
self::SHA1 => 40,
|
||||
self::SHA256, self::SHA3_256 => 64,
|
||||
self::SHA512, self::SHA3_512 => 128,
|
||||
self::XXHASH3 => 16,
|
||||
self::XXHASH64 => 16,
|
||||
};
|
||||
}
|
||||
@@ -45,6 +47,17 @@ enum HashAlgorithm: string
|
||||
|
||||
public static function fast(): self
|
||||
{
|
||||
return extension_loaded('xxhash') ? self::XXHASH64 : self::SHA256;
|
||||
// Prefer xxh3 if available (faster than xxh64)
|
||||
if (in_array('xxh3', hash_algos(), true)) {
|
||||
return self::XXHASH3;
|
||||
}
|
||||
|
||||
// Fallback to xxh64 if available
|
||||
if (in_array('xxh64', hash_algos(), true)) {
|
||||
return self::XXHASH64;
|
||||
}
|
||||
|
||||
// Default to SHA256 if no xxhash algorithms available
|
||||
return self::SHA256;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,8 @@ PHP;
|
||||
$bindings[] = " '{$class}' => \$this->{$methodName}()";
|
||||
}
|
||||
|
||||
return implode(",\n", $bindings);
|
||||
// Add trailing comma if bindings exist (for match expression syntax)
|
||||
return empty($bindings) ? '' : implode(",\n", $bindings) . ',';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ final readonly class ConnectionInitializer
|
||||
// Create a simple database manager for connection only with minimal dependencies
|
||||
$databaseManager = new DatabaseManager(
|
||||
config: $databaseConfig,
|
||||
platform: $databaseConfig->driverConfig->platform,
|
||||
timer: $timer,
|
||||
migrationsPath: 'database/migrations'
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Framework\Core\Events\EventDispatcher;
|
||||
use App\Framework\Database\Cache\EntityCacheManager;
|
||||
use App\Framework\Database\Config\DatabaseConfig;
|
||||
use App\Framework\Database\Platform\MySQLPlatform;
|
||||
use App\Framework\Database\Platform\PostgreSQLPlatform;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\DI\Container;
|
||||
@@ -31,7 +32,7 @@ final readonly class EntityManagerInitializer
|
||||
}
|
||||
|
||||
// Create platform for the database (defaulting to MySQL)
|
||||
$platform = new MySQLPlatform();
|
||||
$platform = new PostgreSQLPlatform();
|
||||
|
||||
$db = new DatabaseManager(
|
||||
$databaseConfig,
|
||||
|
||||
@@ -15,8 +15,8 @@ use App\Framework\ErrorAggregation\Alerting\EmailAlertChannel;
|
||||
use App\Framework\ErrorAggregation\Storage\DatabaseErrorStorage;
|
||||
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Mail\Transport\TransportInterface;
|
||||
use App\Framework\Queue\Queue;
|
||||
use App\Framework\Mail\TransportInterface;
|
||||
|
||||
/**
|
||||
* Initializer for Error Aggregation services
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
|
||||
use App\Framework\Exception\Core\ErrorSeverity;
|
||||
use App\Framework\Exception\ErrorHandlerContext;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Queue\Queue;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
use App\Framework\Exception\Core\ErrorSeverity;
|
||||
use App\Framework\Exception\ErrorHandlerContext;
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Framework\ErrorAggregation;
|
||||
|
||||
use App\Framework\Exception\ErrorHandlerContext;
|
||||
use App\Framework\Exception\Core\ErrorSeverity;
|
||||
|
||||
/**
|
||||
* Null Object implementation for ErrorAggregator
|
||||
|
||||
373
src/Framework/Http/Url.php85/README.md
Normal file
373
src/Framework/Http/Url.php85/README.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# Native PHP 8.5 URL System
|
||||
|
||||
**Zero-dependency URL parsing and manipulation** using native PHP 8.5 `Uri\Rfc3986\Uri` and `Uri\WhatWg\Url`.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Native PHP 8.5 API** - No external dependencies
|
||||
✅ **Dual Spec Support** - RFC 3986 and WHATWG URL Standard
|
||||
✅ **Smart Factory** - Automatic spec selection based on use case
|
||||
✅ **Immutable Withers** - Framework-compliant readonly pattern
|
||||
✅ **Type Safe** - Full type safety with enums and value objects
|
||||
✅ **IDNA/Punycode** - Native international domain name support
|
||||
|
||||
## Installation
|
||||
|
||||
**Requirement**: PHP 8.5+
|
||||
|
||||
No composer packages needed - uses native PHP URL API!
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```php
|
||||
use App\Framework\Http\Url\UrlFactory;
|
||||
|
||||
// Automatic spec selection
|
||||
$url = UrlFactory::parse('https://example.com/path');
|
||||
|
||||
// API Client (RFC 3986)
|
||||
$apiUrl = UrlFactory::forApiClient('https://api.example.com/users');
|
||||
|
||||
// Browser Redirect (WHATWG)
|
||||
$redirect = UrlFactory::forBrowserRedirect('https://app.example.com/dashboard');
|
||||
```
|
||||
|
||||
### URL Manipulation (Immutable)
|
||||
|
||||
```php
|
||||
$url = UrlFactory::forApiClient('https://api.example.com/users');
|
||||
|
||||
// Immutable withers return new instances
|
||||
$withAuth = $url->withUserInfo('api_key', 'secret');
|
||||
$withQuery = $url->withQuery('filter=active&sort=name');
|
||||
$withPath = $url->withPath('/v2/users');
|
||||
|
||||
// Component access
|
||||
echo $url->getScheme(); // 'https'
|
||||
echo $url->getHost(); // 'api.example.com'
|
||||
echo $url->getPath(); // '/users'
|
||||
```
|
||||
|
||||
### Use Case Factory Methods
|
||||
|
||||
```php
|
||||
// API & Server-Side (RFC 3986)
|
||||
UrlFactory::forApiClient($url); // REST, GraphQL, SOAP
|
||||
UrlFactory::forCurlRequest($url); // cURL operations
|
||||
UrlFactory::forSignature($url); // OAuth, AWS signing
|
||||
UrlFactory::forCanonical($url); // SEO, duplicate detection
|
||||
|
||||
// Browser & Client-Side (WHATWG)
|
||||
UrlFactory::forBrowserRedirect($url); // HTTP redirects
|
||||
UrlFactory::forDeepLink($url); // Universal links
|
||||
UrlFactory::forFormAction($url); // HTML form actions
|
||||
UrlFactory::forClientSide($url); // JavaScript fetch()
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### URL Specifications
|
||||
|
||||
**RFC 3986** - Server-side URL handling:
|
||||
- Strict parsing rules
|
||||
- No automatic encoding
|
||||
- Preserves exact structure
|
||||
- Best for: API clients, signatures, cURL
|
||||
|
||||
**WHATWG** - Browser-compatible URL handling:
|
||||
- Living standard (matches browsers)
|
||||
- Automatic percent-encoding
|
||||
- URL normalization
|
||||
- Best for: Redirects, deep links, forms
|
||||
|
||||
### Components
|
||||
|
||||
```
|
||||
src/Framework/Http/Url/
|
||||
├── Url.php # Unified URL interface
|
||||
├── UrlSpec.php # RFC3986 vs WHATWG enum
|
||||
├── UrlUseCase.php # Use case categories
|
||||
├── Rfc3986Url.php # RFC 3986 implementation
|
||||
├── WhatwgUrl.php # WHATWG implementation
|
||||
└── UrlFactory.php # Smart factory
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### API Client with Authentication
|
||||
|
||||
```php
|
||||
$apiUrl = UrlFactory::forApiClient('https://api.example.com/resource');
|
||||
$withAuth = $apiUrl->withUserInfo('api_key', 'secret_token');
|
||||
|
||||
echo $withAuth->toString();
|
||||
// https://api_key:secret_token@api.example.com/resource
|
||||
```
|
||||
|
||||
### URL Signature Generation
|
||||
|
||||
```php
|
||||
$url = UrlFactory::forSignature('https://api.example.com/resource');
|
||||
$withParams = $url->withQuery('timestamp=1234567890&user_id=42');
|
||||
|
||||
// Generate signature from canonical URL string
|
||||
$canonical = $withParams->toString();
|
||||
$signature = hash_hmac('sha256', $canonical, $secretKey);
|
||||
|
||||
$signed = $withParams->withQuery(
|
||||
$withParams->getQuery() . "&signature={$signature}"
|
||||
);
|
||||
```
|
||||
|
||||
### Browser Redirect with Parameters
|
||||
|
||||
```php
|
||||
$redirect = UrlFactory::forBrowserRedirect('https://app.example.com/login');
|
||||
$withReturn = $redirect->withQuery('return_url=/dashboard&status=success');
|
||||
|
||||
// Browser-compatible encoding
|
||||
header('Location: ' . $withReturn->toString());
|
||||
```
|
||||
|
||||
### Deep Link with Fallback
|
||||
|
||||
```php
|
||||
$deepLink = UrlFactory::forDeepLink('myapp://open/article/123');
|
||||
$fallback = UrlFactory::forBrowserRedirect('https://example.com/article/123');
|
||||
|
||||
// Try deep link first, fallback to web
|
||||
$targetUrl = $isNativeApp ? $deepLink : $fallback;
|
||||
```
|
||||
|
||||
### IDNA/Punycode Support
|
||||
|
||||
```php
|
||||
$unicode = UrlFactory::parse('https://例え.jp/path');
|
||||
$ascii = $unicode->toAsciiString();
|
||||
|
||||
echo $ascii; // https://xn--r8jz45g.jp/path
|
||||
```
|
||||
|
||||
### URL Comparison
|
||||
|
||||
```php
|
||||
$url1 = UrlFactory::parse('https://example.com/path#frag1');
|
||||
$url2 = UrlFactory::parse('https://example.com/path#frag2');
|
||||
|
||||
// Ignore fragment by default
|
||||
$url1->equals($url2); // true
|
||||
|
||||
// Include fragment in comparison
|
||||
$url1->equals($url2, includeFragment: true); // false
|
||||
```
|
||||
|
||||
### Relative URL Resolution
|
||||
|
||||
```php
|
||||
$base = UrlFactory::parse('https://example.com/base/path');
|
||||
$resolved = $base->resolve('../other/resource');
|
||||
|
||||
echo $resolved->toString();
|
||||
// https://example.com/other/resource
|
||||
```
|
||||
|
||||
### Spec Conversion
|
||||
|
||||
```php
|
||||
$rfc = UrlFactory::forApiClient('https://example.com/path');
|
||||
$whatwg = UrlFactory::convert($rfc, UrlSpec::WHATWG);
|
||||
|
||||
// Now browser-compatible with normalization
|
||||
```
|
||||
|
||||
## Use Case Guide
|
||||
|
||||
### When to use RFC 3986
|
||||
|
||||
✅ REST API requests
|
||||
✅ URL signature generation (OAuth, AWS)
|
||||
✅ cURL operations
|
||||
✅ Canonical URL generation (SEO)
|
||||
✅ Webhook URLs
|
||||
✅ FTP/SFTP URLs
|
||||
|
||||
### When to use WHATWG
|
||||
|
||||
✅ HTTP redirects
|
||||
✅ Deep links / universal links
|
||||
✅ HTML form actions
|
||||
✅ JavaScript fetch() compatibility
|
||||
✅ Browser-side URL generation
|
||||
✅ Mobile app URLs
|
||||
|
||||
## Testing
|
||||
|
||||
Comprehensive Pest tests included:
|
||||
|
||||
```bash
|
||||
./vendor/bin/pest tests/Unit/Framework/Http/Url/
|
||||
```
|
||||
|
||||
Test coverage:
|
||||
- RFC 3986 parsing and manipulation
|
||||
- WHATWG parsing and normalization
|
||||
- Factory method selection
|
||||
- URL comparison and resolution
|
||||
- IDNA/Punycode handling
|
||||
- Immutability guarantees
|
||||
- Edge cases and error handling
|
||||
|
||||
## Framework Integration
|
||||
|
||||
### Readonly Pattern
|
||||
|
||||
All URL classes are `final readonly` with immutable withers:
|
||||
|
||||
```php
|
||||
final readonly class Rfc3986Url implements Url
|
||||
{
|
||||
private function __construct(
|
||||
private NativeRfc3986Uri $uri
|
||||
) {}
|
||||
|
||||
// Withers return new instances
|
||||
public function withPath(string $path): self
|
||||
{
|
||||
return new self($this->uri->withPath($path));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Value Object Pattern
|
||||
|
||||
URLs are value objects with value semantics:
|
||||
|
||||
```php
|
||||
$url1 = UrlFactory::parse('https://example.com/path');
|
||||
$url2 = UrlFactory::parse('https://example.com/path');
|
||||
|
||||
$url1->equals($url2); // true
|
||||
```
|
||||
|
||||
### DI Container Integration
|
||||
|
||||
Register in container initializer:
|
||||
|
||||
```php
|
||||
final readonly class UrlServiceInitializer implements Initializer
|
||||
{
|
||||
#[Initializer]
|
||||
public function __invoke(): UrlService
|
||||
{
|
||||
return new UrlService(UrlFactory::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Native PHP 8.5 implementation = **C-level performance**:
|
||||
|
||||
- ✅ Zero external dependencies
|
||||
- ✅ No reflection overhead
|
||||
- ✅ Optimized memory usage
|
||||
- ✅ Fast parsing and manipulation
|
||||
- ✅ Native IDNA conversion
|
||||
|
||||
## Migration from Legacy Code
|
||||
|
||||
### Before (primitive strings)
|
||||
|
||||
```php
|
||||
function generateApiUrl(string $baseUrl, array $params): string
|
||||
{
|
||||
$query = http_build_query($params);
|
||||
return $baseUrl . '?' . $query;
|
||||
}
|
||||
```
|
||||
|
||||
### After (type-safe URLs)
|
||||
|
||||
```php
|
||||
function generateApiUrl(Url $baseUrl, array $params): Url
|
||||
{
|
||||
$query = http_build_query($params);
|
||||
return $baseUrl->withQuery($query);
|
||||
}
|
||||
|
||||
// Usage
|
||||
$apiUrl = UrlFactory::forApiClient('https://api.example.com/resource');
|
||||
$withParams = generateApiUrl($apiUrl, ['filter' => 'active']);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use factory methods** for automatic spec selection
|
||||
2. **Prefer specific use case methods** over generic parse()
|
||||
3. **Type hint with Url** for flexibility
|
||||
4. **Use equals() for comparison** instead of string comparison
|
||||
5. **Leverage immutability** - withers are safe for concurrent use
|
||||
6. **Choose correct spec** - RFC 3986 for server, WHATWG for browser
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Native URL Access
|
||||
|
||||
Access underlying native PHP URL objects when needed:
|
||||
|
||||
```php
|
||||
$url = UrlFactory::forApiClient('https://example.com');
|
||||
$nativeUri = $url->getNativeUrl(); // \Uri\Rfc3986\Uri
|
||||
```
|
||||
|
||||
### Custom URL Schemes
|
||||
|
||||
Both specs support custom schemes:
|
||||
|
||||
```php
|
||||
$custom = UrlFactory::parse('myscheme://resource/path');
|
||||
echo $custom->getScheme(); // 'myscheme'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Invalid URL Errors
|
||||
|
||||
```php
|
||||
try {
|
||||
$url = UrlFactory::parse('invalid://url');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// Handle parse error
|
||||
}
|
||||
```
|
||||
|
||||
### Spec Mismatch
|
||||
|
||||
Convert between specs when needed:
|
||||
|
||||
```php
|
||||
$rfc = UrlFactory::forApiClient('https://example.com');
|
||||
$whatwg = UrlFactory::convert($rfc, UrlSpec::WHATWG);
|
||||
```
|
||||
|
||||
### IDNA Issues
|
||||
|
||||
Native PHP 8.5 handles IDNA automatically:
|
||||
|
||||
```php
|
||||
$url = UrlFactory::parse('https://例え.jp');
|
||||
$ascii = $url->toAsciiString(); // Automatic Punycode
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [RFC 3986 Specification](https://www.rfc-editor.org/rfc/rfc3986)
|
||||
- [WHATWG URL Standard](https://url.spec.whatwg.org/)
|
||||
- [PHP 8.5 URL API Documentation](https://www.php.net/manual/en/book.uri.php)
|
||||
- [IDNA/Punycode Reference](https://www.rfc-editor.org/rfc/rfc5891)
|
||||
|
||||
## License
|
||||
|
||||
Part of Custom PHP Framework - Internal Use
|
||||
197
src/Framework/Http/Url.php85/Rfc3986Url.php
Normal file
197
src/Framework/Http/Url.php85/Rfc3986Url.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Url;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
use Uri\Rfc3986\Uri as NativeRfc3986Uri;
|
||||
|
||||
/**
|
||||
* RFC 3986 compliant URL implementation
|
||||
*
|
||||
* Wraps native PHP 8.5+ Uri\Rfc3986\Uri for server-side URL handling.
|
||||
*
|
||||
* Use Cases:
|
||||
* - API clients (REST, GraphQL, SOAP)
|
||||
* - URL signatures (OAuth, AWS, etc.)
|
||||
* - cURL requests
|
||||
* - Server-side canonicalization
|
||||
*
|
||||
* Characteristics:
|
||||
* - Strict RFC 3986 compliance
|
||||
* - No automatic encoding
|
||||
* - Preserves exact URL structure
|
||||
* - Deterministic formatting for signatures
|
||||
*/
|
||||
final readonly class Rfc3986Url implements Url
|
||||
{
|
||||
/**
|
||||
* @param NativeRfc3986Uri $uri Native PHP RFC 3986 URI instance
|
||||
*/
|
||||
private function __construct(
|
||||
private NativeRfc3986Uri $uri
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RFC 3986 URI from string
|
||||
*
|
||||
* @param string $input URI string to parse
|
||||
* @param Url|null $base Optional base URI for relative resolution
|
||||
* @return self New RFC 3986 URL instance
|
||||
* @throws InvalidArgumentException If URI is invalid
|
||||
*/
|
||||
public static function parse(string $input, ?Url $base = null): self
|
||||
{
|
||||
try {
|
||||
if ($base instanceof self) {
|
||||
// RFC 3986 reference resolution with base URI
|
||||
$uri = $base->uri->resolve($input);
|
||||
} else {
|
||||
$uri = NativeRfc3986Uri::parse($input);
|
||||
}
|
||||
|
||||
return new self($uri);
|
||||
} catch (Throwable $e) {
|
||||
throw new InvalidArgumentException(
|
||||
"Failed to parse RFC 3986 URI: {$input}",
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSpec(): UrlSpec
|
||||
{
|
||||
return UrlSpec::RFC3986;
|
||||
}
|
||||
|
||||
// Component Getters
|
||||
|
||||
public function getScheme(): string
|
||||
{
|
||||
return $this->uri->getScheme() ?? '';
|
||||
}
|
||||
|
||||
public function getHost(): string
|
||||
{
|
||||
return $this->uri->getHost() ?? '';
|
||||
}
|
||||
|
||||
public function getPort(): ?int
|
||||
{
|
||||
return $this->uri->getPort();
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->uri->getPath() ?? '';
|
||||
}
|
||||
|
||||
public function getQuery(): string
|
||||
{
|
||||
return $this->uri->getQuery() ?? '';
|
||||
}
|
||||
|
||||
public function getFragment(): string
|
||||
{
|
||||
return $this->uri->getFragment() ?? '';
|
||||
}
|
||||
|
||||
public function getUserInfo(): string
|
||||
{
|
||||
return $this->uri->getUserInfo() ?? '';
|
||||
}
|
||||
|
||||
// Immutable Withers (delegate to native withers)
|
||||
|
||||
public function withScheme(string $scheme): self
|
||||
{
|
||||
return new self($this->uri->withScheme($scheme));
|
||||
}
|
||||
|
||||
public function withHost(string $host): self
|
||||
{
|
||||
return new self($this->uri->withHost($host));
|
||||
}
|
||||
|
||||
public function withPort(?int $port): self
|
||||
{
|
||||
return new self($this->uri->withPort($port));
|
||||
}
|
||||
|
||||
public function withPath(string $path): self
|
||||
{
|
||||
return new self($this->uri->withPath($path));
|
||||
}
|
||||
|
||||
public function withQuery(string $query): self
|
||||
{
|
||||
return new self($this->uri->withQuery($query));
|
||||
}
|
||||
|
||||
public function withFragment(string $fragment): self
|
||||
{
|
||||
return new self($this->uri->withFragment($fragment));
|
||||
}
|
||||
|
||||
public function withUserInfo(string $user, ?string $password = null): self
|
||||
{
|
||||
$userInfo = $password !== null ? "{$user}:{$password}" : $user;
|
||||
|
||||
return new self($this->uri->withUserInfo($userInfo));
|
||||
}
|
||||
|
||||
// Serialization
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->uri->toString();
|
||||
}
|
||||
|
||||
public function toAsciiString(): string
|
||||
{
|
||||
// Native PHP 8.5 handles IDNA/Punycode conversion
|
||||
return $this->uri->toRawString();
|
||||
}
|
||||
|
||||
// Utilities
|
||||
|
||||
public function resolve(string $relative): self
|
||||
{
|
||||
$resolved = $this->uri->resolve($relative);
|
||||
|
||||
return new self($resolved);
|
||||
}
|
||||
|
||||
public function equals(Url $other, bool $includeFragment = false): bool
|
||||
{
|
||||
if (! $other instanceof self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($includeFragment) {
|
||||
return $this->uri->equals($other->uri);
|
||||
}
|
||||
|
||||
// Compare without fragments
|
||||
$thisWithoutFragment = $this->uri->withFragment(null);
|
||||
$otherWithoutFragment = $other->uri->withFragment(null);
|
||||
|
||||
return $thisWithoutFragment->equals($otherWithoutFragment);
|
||||
}
|
||||
|
||||
public function getNativeUrl(): NativeRfc3986Uri
|
||||
{
|
||||
return $this->uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation (allows string casting)
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->uri->toString();
|
||||
}
|
||||
}
|
||||
191
src/Framework/Http/Url.php85/Url.php
Normal file
191
src/Framework/Http/Url.php85/Url.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Url;
|
||||
|
||||
/**
|
||||
* Unified URL interface for native PHP 8.5 URL API
|
||||
*
|
||||
* Abstracts RFC 3986 and WHATWG URL implementations
|
||||
* providing a unified API surface for URL manipulation.
|
||||
*
|
||||
* Replaces old Uri class with full-featured URL handling.
|
||||
*
|
||||
* All implementations must be immutable - modification methods
|
||||
* return new instances (wither pattern).
|
||||
*/
|
||||
interface Url
|
||||
{
|
||||
/**
|
||||
* Parse URL from string with optional base URL
|
||||
*
|
||||
* @param string $input URL string to parse
|
||||
* @param self|null $base Optional base URL for relative resolution
|
||||
* @return self New URL instance
|
||||
* @throws \InvalidArgumentException If URL is invalid
|
||||
*/
|
||||
public static function parse(string $input, ?self $base = null): self;
|
||||
|
||||
/**
|
||||
* Get URL specification this instance conforms to
|
||||
*
|
||||
* @return UrlSpec Either RFC3986 or WHATWG
|
||||
*/
|
||||
public function getSpec(): UrlSpec;
|
||||
|
||||
// Component Getters
|
||||
|
||||
/**
|
||||
* Get URL scheme (e.g., 'https', 'ftp')
|
||||
*
|
||||
* @return string Scheme without trailing colon, empty string if absent
|
||||
*/
|
||||
public function getScheme(): string;
|
||||
|
||||
/**
|
||||
* Get host component (domain or IP address)
|
||||
*
|
||||
* @return string Host, empty string if absent
|
||||
*/
|
||||
public function getHost(): string;
|
||||
|
||||
/**
|
||||
* Get port number
|
||||
*
|
||||
* @return int|null Port number, null if default or absent
|
||||
*/
|
||||
public function getPort(): ?int;
|
||||
|
||||
/**
|
||||
* Get path component
|
||||
*
|
||||
* @return string Path, empty string if absent
|
||||
*/
|
||||
public function getPath(): string;
|
||||
|
||||
/**
|
||||
* Get query string
|
||||
*
|
||||
* @return string Query without leading '?', empty string if absent
|
||||
*/
|
||||
public function getQuery(): string;
|
||||
|
||||
/**
|
||||
* Get fragment identifier
|
||||
*
|
||||
* @return string Fragment without leading '#', empty string if absent
|
||||
*/
|
||||
public function getFragment(): string;
|
||||
|
||||
/**
|
||||
* Get user info (username:password)
|
||||
*
|
||||
* @return string User info, empty string if absent
|
||||
*/
|
||||
public function getUserInfo(): string;
|
||||
|
||||
// Immutable Withers (Framework Pattern)
|
||||
|
||||
/**
|
||||
* Return instance with specified scheme
|
||||
*
|
||||
* @param string $scheme New scheme
|
||||
* @return self New instance with updated scheme
|
||||
*/
|
||||
public function withScheme(string $scheme): self;
|
||||
|
||||
/**
|
||||
* Return instance with specified host
|
||||
*
|
||||
* @param string $host New host
|
||||
* @return self New instance with updated host
|
||||
*/
|
||||
public function withHost(string $host): self;
|
||||
|
||||
/**
|
||||
* Return instance with specified port
|
||||
*
|
||||
* @param int|null $port New port, null for default
|
||||
* @return self New instance with updated port
|
||||
*/
|
||||
public function withPort(?int $port): self;
|
||||
|
||||
/**
|
||||
* Return instance with specified path
|
||||
*
|
||||
* @param string $path New path
|
||||
* @return self New instance with updated path
|
||||
*/
|
||||
public function withPath(string $path): self;
|
||||
|
||||
/**
|
||||
* Return instance with specified query
|
||||
*
|
||||
* @param string $query New query string (without leading '?')
|
||||
* @return self New instance with updated query
|
||||
*/
|
||||
public function withQuery(string $query): self;
|
||||
|
||||
/**
|
||||
* Return instance with specified fragment
|
||||
*
|
||||
* @param string $fragment New fragment (without leading '#')
|
||||
* @return self New instance with updated fragment
|
||||
*/
|
||||
public function withFragment(string $fragment): self;
|
||||
|
||||
/**
|
||||
* Return instance with specified user info
|
||||
*
|
||||
* @param string $user Username
|
||||
* @param string|null $password Optional password
|
||||
* @return self New instance with updated user info
|
||||
*/
|
||||
public function withUserInfo(string $user, ?string $password = null): self;
|
||||
|
||||
// Serialization
|
||||
|
||||
/**
|
||||
* Convert URL to string representation
|
||||
*
|
||||
* @return string Complete URL string
|
||||
*/
|
||||
public function toString(): string;
|
||||
|
||||
/**
|
||||
* Convert URL to ASCII-compatible string (Punycode/IDNA)
|
||||
*
|
||||
* @return string ASCII-encoded URL for international domain names
|
||||
*/
|
||||
public function toAsciiString(): string;
|
||||
|
||||
// Utilities
|
||||
|
||||
/**
|
||||
* Resolve relative URL against this URL as base
|
||||
*
|
||||
* @param string $relative Relative URL to resolve
|
||||
* @return self New URL instance with resolved URL
|
||||
*/
|
||||
public function resolve(string $relative): self;
|
||||
|
||||
/**
|
||||
* Check equality with another URL
|
||||
*
|
||||
* @param self $other URL to compare
|
||||
* @param bool $includeFragment Whether to include fragment in comparison
|
||||
* @return bool True if URLs are equal
|
||||
*/
|
||||
public function equals(self $other, bool $includeFragment = false): bool;
|
||||
|
||||
/**
|
||||
* Get underlying native PHP URL object
|
||||
*
|
||||
* Provides access to native \Uri\Rfc3986\Uri or \Uri\WhatWg\Url
|
||||
* for advanced use cases requiring direct native API access.
|
||||
*
|
||||
* @return object Native PHP URL object
|
||||
*/
|
||||
public function getNativeUrl(): object;
|
||||
}
|
||||
229
src/Framework/Http/Url.php85/UrlFactory.php
Normal file
229
src/Framework/Http/Url.php85/UrlFactory.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Url;
|
||||
|
||||
/**
|
||||
* Smart URL factory with automatic spec selection
|
||||
*
|
||||
* Provides convenient factory methods that automatically select
|
||||
* the appropriate URL specification (RFC 3986 vs WHATWG) based
|
||||
* on the intended use case.
|
||||
*
|
||||
* Usage:
|
||||
* ```php
|
||||
* // API Client (RFC 3986)
|
||||
* $apiUrl = UrlFactory::forApiClient('https://api.example.com/users');
|
||||
*
|
||||
* // Browser Redirect (WHATWG)
|
||||
* $redirect = UrlFactory::forBrowserRedirect('https://example.com/dashboard');
|
||||
*
|
||||
* // Automatic selection
|
||||
* $url = UrlFactory::forUseCase(UrlUseCase::DEEP_LINK, 'myapp://open');
|
||||
* ```
|
||||
*/
|
||||
final readonly class UrlFactory
|
||||
{
|
||||
/**
|
||||
* Parse URL with automatic spec detection
|
||||
*
|
||||
* Attempts to select appropriate spec based on scheme and structure.
|
||||
* Defaults to RFC 3986 for ambiguous cases.
|
||||
*
|
||||
* @param string $input URL string to parse
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return Url Parsed URL with selected spec
|
||||
*/
|
||||
public static function parse(string $input, ?Url $base = null): Url
|
||||
{
|
||||
// Heuristic: Use WHATWG for http/https URLs, RFC 3986 for others
|
||||
$scheme = parse_url($input, PHP_URL_SCHEME);
|
||||
|
||||
if (in_array($scheme, ['http', 'https', 'file', 'ws', 'wss'], true)) {
|
||||
return WhatwgUrl::parse($input, $base);
|
||||
}
|
||||
|
||||
return Rfc3986Url::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL for specific use case with automatic spec selection
|
||||
*
|
||||
* @param UrlUseCase $useCase Intended use case
|
||||
* @param string $input URL string to parse
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return Url Parsed URL with spec matching use case
|
||||
*/
|
||||
public static function forUseCase(UrlUseCase $useCase, string $input, ?Url $base = null): Url
|
||||
{
|
||||
$spec = UrlSpec::forUseCase($useCase);
|
||||
|
||||
return match ($spec) {
|
||||
UrlSpec::RFC3986 => Rfc3986Url::parse($input, $base),
|
||||
UrlSpec::WHATWG => WhatwgUrl::parse($input, $base),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL for API client use (RFC 3986)
|
||||
*
|
||||
* Best for:
|
||||
* - REST API calls
|
||||
* - GraphQL endpoints
|
||||
* - SOAP services
|
||||
* - Webhook URLs
|
||||
*
|
||||
* @param string $input API endpoint URL
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return Rfc3986Url RFC 3986 compliant URL
|
||||
*/
|
||||
public static function forApiClient(string $input, ?Url $base = null): Rfc3986Url
|
||||
{
|
||||
return Rfc3986Url::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL for cURL request (RFC 3986)
|
||||
*
|
||||
* Best for:
|
||||
* - cURL operations
|
||||
* - HTTP client requests
|
||||
* - File transfers
|
||||
*
|
||||
* @param string $input Request URL
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return Rfc3986Url RFC 3986 compliant URL
|
||||
*/
|
||||
public static function forCurlRequest(string $input, ?Url $base = null): Rfc3986Url
|
||||
{
|
||||
return Rfc3986Url::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL for signature generation (RFC 3986)
|
||||
*
|
||||
* Best for:
|
||||
* - OAuth signatures
|
||||
* - AWS request signing
|
||||
* - HMAC-based authentication
|
||||
* - Webhook signature verification
|
||||
*
|
||||
* @param string $input URL to sign
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return Rfc3986Url RFC 3986 compliant URL
|
||||
*/
|
||||
public static function forSignature(string $input, ?Url $base = null): Rfc3986Url
|
||||
{
|
||||
return Rfc3986Url::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create canonical URL (RFC 3986)
|
||||
*
|
||||
* Best for:
|
||||
* - SEO canonical URLs
|
||||
* - Duplicate content detection
|
||||
* - URL normalization
|
||||
* - Sitemap generation
|
||||
*
|
||||
* @param string $input URL to canonicalize
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return Rfc3986Url RFC 3986 compliant URL
|
||||
*/
|
||||
public static function forCanonical(string $input, ?Url $base = null): Rfc3986Url
|
||||
{
|
||||
return Rfc3986Url::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL for browser redirect (WHATWG)
|
||||
*
|
||||
* Best for:
|
||||
* - HTTP redirects (302, 301, etc.)
|
||||
* - Location headers
|
||||
* - User-facing redirects
|
||||
*
|
||||
* @param string $input Redirect target URL
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return WhatwgUrl WHATWG compliant URL
|
||||
*/
|
||||
public static function forBrowserRedirect(string $input, ?Url $base = null): WhatwgUrl
|
||||
{
|
||||
return WhatwgUrl::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL for deep link (WHATWG)
|
||||
*
|
||||
* Best for:
|
||||
* - Universal links
|
||||
* - App deep links
|
||||
* - Mobile-to-web links
|
||||
* - Cross-platform navigation
|
||||
*
|
||||
* @param string $input Deep link URL
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return WhatwgUrl WHATWG compliant URL
|
||||
*/
|
||||
public static function forDeepLink(string $input, ?Url $base = null): WhatwgUrl
|
||||
{
|
||||
return WhatwgUrl::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL for HTML form action (WHATWG)
|
||||
*
|
||||
* Best for:
|
||||
* - Form submission targets
|
||||
* - HTML5 form actions
|
||||
* - Browser form handling
|
||||
*
|
||||
* @param string $input Form action URL
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return WhatwgUrl WHATWG compliant URL
|
||||
*/
|
||||
public static function forFormAction(string $input, ?Url $base = null): WhatwgUrl
|
||||
{
|
||||
return WhatwgUrl::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL for client-side JavaScript (WHATWG)
|
||||
*
|
||||
* Best for:
|
||||
* - JavaScript fetch() API
|
||||
* - XMLHttpRequest URLs
|
||||
* - Browser URL API compatibility
|
||||
* - Client-side routing
|
||||
*
|
||||
* @param string $input JavaScript URL
|
||||
* @param Url|null $base Optional base URL
|
||||
* @return WhatwgUrl WHATWG compliant URL
|
||||
*/
|
||||
public static function forClientSide(string $input, ?Url $base = null): WhatwgUrl
|
||||
{
|
||||
return WhatwgUrl::parse($input, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert between URL specs
|
||||
*
|
||||
* @param Url $url URL to convert
|
||||
* @param UrlSpec $targetSpec Target specification
|
||||
* @return Url Converted URL
|
||||
*/
|
||||
public static function convert(Url $url, UrlSpec $targetSpec): Url
|
||||
{
|
||||
if ($url->getSpec() === $targetSpec) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$urlString = $url->toString();
|
||||
|
||||
return match ($targetSpec) {
|
||||
UrlSpec::RFC3986 => Rfc3986Url::parse($urlString),
|
||||
UrlSpec::WHATWG => WhatwgUrl::parse($urlString),
|
||||
};
|
||||
}
|
||||
}
|
||||
97
src/Framework/Http/Url.php85/UrlSpec.php
Normal file
97
src/Framework/Http/Url.php85/UrlSpec.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Url;
|
||||
|
||||
/**
|
||||
* URL Specification identifier
|
||||
*
|
||||
* Distinguishes between RFC 3986 and WHATWG URL Standard
|
||||
* for different parsing and handling semantics.
|
||||
*/
|
||||
enum UrlSpec: string
|
||||
{
|
||||
/**
|
||||
* RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
|
||||
*
|
||||
* Use for:
|
||||
* - Server-side URL canonicalization
|
||||
* - API clients (REST, GraphQL, SOAP)
|
||||
* - URL signatures and validation
|
||||
* - cURL compatibility
|
||||
* - File system paths
|
||||
*
|
||||
* Characteristics:
|
||||
* - Strict parsing rules
|
||||
* - No automatic encoding
|
||||
* - No URL normalization
|
||||
* - Preserves original structure
|
||||
*
|
||||
* Example:
|
||||
* ```php
|
||||
* $uri = Rfc3986Url::parse('https://api.example.com/users?id=123');
|
||||
* ```
|
||||
*/
|
||||
case RFC3986 = 'rfc3986';
|
||||
|
||||
/**
|
||||
* WHATWG URL Standard (Living Standard)
|
||||
*
|
||||
* Use for:
|
||||
* - Browser-like URL handling
|
||||
* - Deep links and redirects
|
||||
* - Client-side generated URLs
|
||||
* - HTML form actions
|
||||
* - JavaScript fetch() API compatibility
|
||||
*
|
||||
* Characteristics:
|
||||
* - Living standard (matches modern browsers)
|
||||
* - Automatic percent-encoding
|
||||
* - URL normalization
|
||||
* - Special scheme handling (http, https, file, etc.)
|
||||
*
|
||||
* Example:
|
||||
* ```php
|
||||
* $url = WhatwgUrl::parse('https://example.com/redirect');
|
||||
* ```
|
||||
*/
|
||||
case WHATWG = 'whatwg';
|
||||
|
||||
/**
|
||||
* Get recommended spec for specific use case
|
||||
*
|
||||
* Automatically selects the appropriate URL specification
|
||||
* based on the intended usage pattern.
|
||||
*/
|
||||
public static function forUseCase(UrlUseCase $useCase): self
|
||||
{
|
||||
return match ($useCase) {
|
||||
UrlUseCase::API_CLIENT,
|
||||
UrlUseCase::CURL_REQUEST,
|
||||
UrlUseCase::SIGNATURE_GENERATION,
|
||||
UrlUseCase::CANONICAL_URL => self::RFC3986,
|
||||
|
||||
UrlUseCase::BROWSER_REDIRECT,
|
||||
UrlUseCase::DEEP_LINK,
|
||||
UrlUseCase::HTML_FORM_ACTION,
|
||||
UrlUseCase::CLIENT_SIDE_URL => self::WHATWG,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this spec is RFC 3986
|
||||
*/
|
||||
public function isRfc3986(): bool
|
||||
{
|
||||
return $this === self::RFC3986;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this spec is WHATWG
|
||||
*/
|
||||
public function isWhatwg(): bool
|
||||
{
|
||||
return $this === self::WHATWG;
|
||||
}
|
||||
}
|
||||
119
src/Framework/Http/Url.php85/UrlUseCase.php
Normal file
119
src/Framework/Http/Url.php85/UrlUseCase.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Url;
|
||||
|
||||
/**
|
||||
* URL Use Case categories for automatic spec selection
|
||||
*
|
||||
* Helps determine whether RFC 3986 or WHATWG URL Standard
|
||||
* should be used based on the intended usage pattern.
|
||||
*/
|
||||
enum UrlUseCase
|
||||
{
|
||||
/**
|
||||
* API Client requests (REST, GraphQL, SOAP)
|
||||
*
|
||||
* Recommended Spec: RFC 3986
|
||||
* - Strict parsing for API endpoints
|
||||
* - URL signature compatibility
|
||||
* - Predictable canonicalization
|
||||
*/
|
||||
case API_CLIENT;
|
||||
|
||||
/**
|
||||
* cURL requests and HTTP client operations
|
||||
*
|
||||
* Recommended Spec: RFC 3986
|
||||
* - Compatible with cURL expectations
|
||||
* - No automatic normalization
|
||||
* - Preserves exact URL structure
|
||||
*/
|
||||
case CURL_REQUEST;
|
||||
|
||||
/**
|
||||
* URL signature generation (OAuth, AWS, etc.)
|
||||
*
|
||||
* Recommended Spec: RFC 3986
|
||||
* - Deterministic URL formatting
|
||||
* - No automatic encoding changes
|
||||
* - Critical for signature validation
|
||||
*/
|
||||
case SIGNATURE_GENERATION;
|
||||
|
||||
/**
|
||||
* Canonical URL generation (SEO, duplicate detection)
|
||||
*
|
||||
* Recommended Spec: RFC 3986
|
||||
* - Consistent URL representation
|
||||
* - Reliable comparison
|
||||
* - SEO-friendly formatting
|
||||
*/
|
||||
case CANONICAL_URL;
|
||||
|
||||
/**
|
||||
* Browser redirect URLs
|
||||
*
|
||||
* Recommended Spec: WHATWG
|
||||
* - Browser-compatible behavior
|
||||
* - Automatic encoding
|
||||
* - Matches browser expectations
|
||||
*/
|
||||
case BROWSER_REDIRECT;
|
||||
|
||||
/**
|
||||
* Deep links (app-to-web, universal links)
|
||||
*
|
||||
* Recommended Spec: WHATWG
|
||||
* - Mobile browser compatibility
|
||||
* - Modern URL handling
|
||||
* - Cross-platform consistency
|
||||
*/
|
||||
case DEEP_LINK;
|
||||
|
||||
/**
|
||||
* HTML form action URLs
|
||||
*
|
||||
* Recommended Spec: WHATWG
|
||||
* - HTML5 specification compliance
|
||||
* - Browser form submission compatibility
|
||||
* - Automatic encoding of form data
|
||||
*/
|
||||
case HTML_FORM_ACTION;
|
||||
|
||||
/**
|
||||
* Client-side generated URLs (JavaScript compatibility)
|
||||
*
|
||||
* Recommended Spec: WHATWG
|
||||
* - Matches JavaScript URL API
|
||||
* - Compatible with fetch()
|
||||
* - Consistent with browser behavior
|
||||
*/
|
||||
case CLIENT_SIDE_URL;
|
||||
|
||||
/**
|
||||
* Get human-readable description of this use case
|
||||
*/
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::API_CLIENT => 'API client requests (REST, GraphQL, SOAP)',
|
||||
self::CURL_REQUEST => 'cURL requests and HTTP client operations',
|
||||
self::SIGNATURE_GENERATION => 'URL signature generation (OAuth, AWS)',
|
||||
self::CANONICAL_URL => 'Canonical URL generation (SEO)',
|
||||
self::BROWSER_REDIRECT => 'Browser redirect URLs',
|
||||
self::DEEP_LINK => 'Deep links and universal links',
|
||||
self::HTML_FORM_ACTION => 'HTML form action URLs',
|
||||
self::CLIENT_SIDE_URL => 'Client-side JavaScript URLs',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended URL spec for this use case
|
||||
*/
|
||||
public function recommendedSpec(): UrlSpec
|
||||
{
|
||||
return UrlSpec::forUseCase($this);
|
||||
}
|
||||
}
|
||||
204
src/Framework/Http/Url.php85/WhatwgUrl.php
Normal file
204
src/Framework/Http/Url.php85/WhatwgUrl.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Url;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Uri\WhatWg\Url as NativeWhatwgUrl;
|
||||
|
||||
/**
|
||||
* WHATWG URL Standard implementation
|
||||
*
|
||||
* Wraps native PHP 8.5+ Uri\WhatWg\Url for browser-compatible URL handling.
|
||||
*
|
||||
* Use Cases:
|
||||
* - Browser redirects
|
||||
* - Deep links and universal links
|
||||
* - HTML form actions
|
||||
* - Client-side JavaScript compatibility
|
||||
*
|
||||
* Characteristics:
|
||||
* - Living Standard (matches modern browsers)
|
||||
* - Automatic percent-encoding
|
||||
* - URL normalization
|
||||
* - Special scheme handling (http, https, file, etc.)
|
||||
*/
|
||||
final readonly class WhatwgUrl implements Url
|
||||
{
|
||||
/**
|
||||
* @param NativeWhatwgUrl $url Native PHP WHATWG URL instance
|
||||
*/
|
||||
private function __construct(
|
||||
private NativeWhatwgUrl $url
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse WHATWG URL from string
|
||||
*
|
||||
* @param string $input URL string to parse
|
||||
* @param Url|null $base Optional base URL for relative resolution
|
||||
* @return self New WHATWG URL instance
|
||||
* @throws InvalidArgumentException If URL is invalid
|
||||
*/
|
||||
public static function parse(string $input, ?Url $base = null): self
|
||||
{
|
||||
try {
|
||||
if ($base instanceof self) {
|
||||
// WHATWG URL resolution with base
|
||||
$url = $base->url->resolve($input);
|
||||
} else {
|
||||
$url = new NativeWhatwgUrl($input);
|
||||
}
|
||||
|
||||
return new self($url);
|
||||
} catch (\Throwable $e) {
|
||||
throw new InvalidArgumentException(
|
||||
"Failed to parse WHATWG URL: {$input}",
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSpec(): UrlSpec
|
||||
{
|
||||
return UrlSpec::WHATWG;
|
||||
}
|
||||
|
||||
// Component Getters (WHATWG methods)
|
||||
|
||||
public function getScheme(): string
|
||||
{
|
||||
return $this->url->getScheme() ?? '';
|
||||
}
|
||||
|
||||
public function getHost(): string
|
||||
{
|
||||
// Prefer Unicode host for display
|
||||
return $this->url->getUnicodeHost() ?? '';
|
||||
}
|
||||
|
||||
public function getPort(): ?int
|
||||
{
|
||||
return $this->url->getPort();
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->url->getPath() ?? '';
|
||||
}
|
||||
|
||||
public function getQuery(): string
|
||||
{
|
||||
return $this->url->getQuery() ?? '';
|
||||
}
|
||||
|
||||
public function getFragment(): string
|
||||
{
|
||||
return $this->url->getFragment() ?? '';
|
||||
}
|
||||
|
||||
public function getUserInfo(): string
|
||||
{
|
||||
$user = $this->url->getUsername() ?? '';
|
||||
$pass = $this->url->getPassword() ?? '';
|
||||
|
||||
if ($user === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $pass !== '' ? "{$user}:{$pass}" : $user;
|
||||
}
|
||||
|
||||
// Immutable Withers (delegate to native withers)
|
||||
|
||||
public function withScheme(string $scheme): self
|
||||
{
|
||||
return new self($this->url->withScheme($scheme));
|
||||
}
|
||||
|
||||
public function withHost(string $host): self
|
||||
{
|
||||
return new self($this->url->withHost($host));
|
||||
}
|
||||
|
||||
public function withPort(?int $port): self
|
||||
{
|
||||
return new self($this->url->withPort($port));
|
||||
}
|
||||
|
||||
public function withPath(string $path): self
|
||||
{
|
||||
return new self($this->url->withPath($path));
|
||||
}
|
||||
|
||||
public function withQuery(string $query): self
|
||||
{
|
||||
return new self($this->url->withQuery($query !== '' ? $query : null));
|
||||
}
|
||||
|
||||
public function withFragment(string $fragment): self
|
||||
{
|
||||
return new self($this->url->withFragment($fragment !== '' ? $fragment : null));
|
||||
}
|
||||
|
||||
public function withUserInfo(string $user, ?string $password = null): self
|
||||
{
|
||||
$withUser = $this->url->withUsername($user);
|
||||
|
||||
return new self($withUser->withPassword($password ?? ''));
|
||||
}
|
||||
|
||||
// Serialization
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->url->toUnicodeString();
|
||||
}
|
||||
|
||||
public function toAsciiString(): string
|
||||
{
|
||||
// WHATWG URLs with Punycode encoding
|
||||
return $this->url->toAsciiString();
|
||||
}
|
||||
|
||||
// Utilities
|
||||
|
||||
public function resolve(string $relative): self
|
||||
{
|
||||
$resolved = $this->url->resolve($relative);
|
||||
|
||||
return new self($resolved);
|
||||
}
|
||||
|
||||
public function equals(Url $other, bool $includeFragment = false): bool
|
||||
{
|
||||
if (! $other instanceof self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($includeFragment) {
|
||||
return $this->url->equals($other->url);
|
||||
}
|
||||
|
||||
// Compare without fragments
|
||||
$thisWithoutFragment = $this->url->withFragment(null);
|
||||
$otherWithoutFragment = $other->url->withFragment(null);
|
||||
|
||||
return $thisWithoutFragment->equals($otherWithoutFragment);
|
||||
}
|
||||
|
||||
public function getNativeUrl(): NativeWhatwgUrl
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation (allows string casting)
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->url->toUnicodeString();
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
||||
unset($predictionKeys[$i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Convert timestamp back to DateTimeImmutable
|
||||
if (is_int($prediction['timestamp'])) {
|
||||
$prediction['timestamp'] = (new \DateTimeImmutable())->setTimestamp($prediction['timestamp']);
|
||||
@@ -174,6 +174,104 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
|
||||
return $deletedCount;
|
||||
}
|
||||
|
||||
public function getRecentPredictions(
|
||||
string $modelName,
|
||||
Version $version,
|
||||
int $limit
|
||||
): array {
|
||||
$indexKey = $this->getPredictionsIndexKey($modelName, $version);
|
||||
$result = $this->cache->get($indexKey);
|
||||
$predictionKeys = $result->value ?? [];
|
||||
|
||||
if (empty($predictionKeys)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$predictions = [];
|
||||
|
||||
// Get predictions in reverse order (most recent first)
|
||||
foreach (array_reverse($predictionKeys) as $keyString) {
|
||||
if (count($predictions) >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$predictionKey = CacheKey::fromString($keyString);
|
||||
$result = $this->cache->get($predictionKey);
|
||||
|
||||
$prediction = $result->value;
|
||||
|
||||
if ($prediction === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert timestamp back to DateTimeImmutable
|
||||
if (is_int($prediction['timestamp'])) {
|
||||
$prediction['timestamp'] = (new \DateTimeImmutable())->setTimestamp($prediction['timestamp']);
|
||||
}
|
||||
|
||||
$predictions[] = $prediction;
|
||||
}
|
||||
|
||||
return $predictions;
|
||||
}
|
||||
|
||||
public function calculateAccuracy(
|
||||
string $modelName,
|
||||
Version $version,
|
||||
int $limit
|
||||
): float {
|
||||
$predictions = $this->getRecentPredictions($modelName, $version, $limit);
|
||||
|
||||
if (empty($predictions)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$correctCount = 0;
|
||||
$totalCount = 0;
|
||||
|
||||
foreach ($predictions as $prediction) {
|
||||
// Only count predictions that have actual labels for accuracy calculation
|
||||
if (!isset($prediction['actual_label'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalCount++;
|
||||
|
||||
if (isset($prediction['predicted_label'])
|
||||
&& $prediction['predicted_label'] === $prediction['actual_label']) {
|
||||
$correctCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalCount === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $correctCount / $totalCount;
|
||||
}
|
||||
|
||||
public function getConfidenceBaseline(
|
||||
string $modelName,
|
||||
Version $version
|
||||
): ?array {
|
||||
$baselineKey = CacheKey::fromString(
|
||||
self::CACHE_PREFIX . ":{$modelName}:{$version->toString()}:baseline"
|
||||
);
|
||||
|
||||
$result = $this->cache->get($baselineKey);
|
||||
$baseline = $result->value;
|
||||
|
||||
if ($baseline === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'avg_confidence' => $baseline['avg_confidence'],
|
||||
'std_dev_confidence' => $baseline['std_dev_confidence'],
|
||||
'stored_at' => $baseline['stored_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add prediction key to index
|
||||
*/
|
||||
|
||||
@@ -97,6 +97,86 @@ final class InMemoryPerformanceStorage implements PerformanceStorage
|
||||
return $initialCount - count($this->predictions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent predictions with limit
|
||||
*/
|
||||
public function getRecentPredictions(
|
||||
string $modelName,
|
||||
Version $version,
|
||||
int $limit
|
||||
): array {
|
||||
// Filter by model and version
|
||||
$filtered = array_filter(
|
||||
$this->predictions,
|
||||
fn($record) =>
|
||||
$record['model_name'] === $modelName
|
||||
&& $record['version'] === $version->toString()
|
||||
);
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
usort($filtered, fn($a, $b) => $b['timestamp'] <=> $a['timestamp']);
|
||||
|
||||
// Limit results
|
||||
return array_values(array_slice($filtered, 0, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate accuracy from recent predictions
|
||||
*/
|
||||
public function calculateAccuracy(
|
||||
string $modelName,
|
||||
Version $version,
|
||||
int $limit
|
||||
): float {
|
||||
$predictions = $this->getRecentPredictions($modelName, $version, $limit);
|
||||
|
||||
if (empty($predictions)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$correctCount = 0;
|
||||
$totalCount = 0;
|
||||
|
||||
foreach ($predictions as $prediction) {
|
||||
// Only count predictions that have actual labels
|
||||
if (!isset($prediction['actual_label'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalCount++;
|
||||
|
||||
if (isset($prediction['predicted_label'])
|
||||
&& $prediction['predicted_label'] === $prediction['actual_label']) {
|
||||
$correctCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalCount === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $correctCount / $totalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confidence baseline as array
|
||||
*/
|
||||
public function getConfidenceBaseline(
|
||||
string $modelName,
|
||||
Version $version
|
||||
): ?array {
|
||||
$key = $this->getBaselineKey($modelName, $version);
|
||||
|
||||
if (!isset($this->confidenceBaselines[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'avg_confidence' => $this->confidenceBaselines[$key]['avg'],
|
||||
'std_dev_confidence' => $this->confidenceBaselines[$key]['stdDev'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get baseline key for confidence storage
|
||||
*/
|
||||
|
||||
455
src/Framework/Mcp/Tools/GiteaTools.php
Normal file
455
src/Framework/Mcp/Tools/GiteaTools.php
Normal file
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mcp\Tools;
|
||||
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
use App\Framework\HttpClient\HttpMethod;
|
||||
use App\Framework\Mcp\McpTool;
|
||||
|
||||
/**
|
||||
* Gitea Repository Management MCP Tools
|
||||
*
|
||||
* Provides AI-accessible Gitea API operations for repository management,
|
||||
* SSH key setup, and deployment automation.
|
||||
*/
|
||||
final readonly class GiteaTools
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClient $httpClient,
|
||||
private string $giteaUrl,
|
||||
private string $giteaUsername,
|
||||
private string $giteaPassword
|
||||
) {
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_create_repository',
|
||||
description: 'Create a new repository in Gitea'
|
||||
)]
|
||||
public function createRepository(
|
||||
string $name,
|
||||
string $description = '',
|
||||
bool $private = true,
|
||||
bool $autoInit = false,
|
||||
string $defaultBranch = 'main'
|
||||
): array {
|
||||
$url = "{$this->giteaUrl}/api/v1/user/repos";
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'private' => $private,
|
||||
'auto_init' => $autoInit,
|
||||
'default_branch' => $defaultBranch,
|
||||
];
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
|
||||
|
||||
if ($result['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'repository' => [
|
||||
'name' => $result['response']['name'] ?? $name,
|
||||
'full_name' => $result['response']['full_name'] ?? "{$this->giteaUsername}/$name",
|
||||
'clone_url' => $result['response']['clone_url'] ?? null,
|
||||
'ssh_url' => $result['response']['ssh_url'] ?? null,
|
||||
'html_url' => $result['response']['html_url'] ?? null,
|
||||
'private' => $result['response']['private'] ?? $private,
|
||||
'id' => $result['response']['id'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_list_repositories',
|
||||
description: 'List all repositories for the authenticated user'
|
||||
)]
|
||||
public function listRepositories(): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/user/repos";
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::GET, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
$repos = array_map(function ($repo) {
|
||||
return [
|
||||
'name' => $repo['name'] ?? 'unknown',
|
||||
'full_name' => $repo['full_name'] ?? 'unknown',
|
||||
'description' => $repo['description'] ?? '',
|
||||
'private' => $repo['private'] ?? false,
|
||||
'clone_url' => $repo['clone_url'] ?? null,
|
||||
'ssh_url' => $repo['ssh_url'] ?? null,
|
||||
'html_url' => $repo['html_url'] ?? null,
|
||||
];
|
||||
}, $result['response'] ?? []);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'repositories' => $repos,
|
||||
'count' => count($repos),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_get_repository',
|
||||
description: 'Get details of a specific repository'
|
||||
)]
|
||||
public function getRepository(string $owner, string $repo): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::GET, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
$repo = $result['response'];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'repository' => [
|
||||
'name' => $repo['name'] ?? 'unknown',
|
||||
'full_name' => $repo['full_name'] ?? 'unknown',
|
||||
'description' => $repo['description'] ?? '',
|
||||
'private' => $repo['private'] ?? false,
|
||||
'clone_url' => $repo['clone_url'] ?? null,
|
||||
'ssh_url' => $repo['ssh_url'] ?? null,
|
||||
'html_url' => $repo['html_url'] ?? null,
|
||||
'default_branch' => $repo['default_branch'] ?? 'main',
|
||||
'created_at' => $repo['created_at'] ?? null,
|
||||
'updated_at' => $repo['updated_at'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_delete_repository',
|
||||
description: 'Delete a repository'
|
||||
)]
|
||||
public function deleteRepository(string $owner, string $repo): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
|
||||
|
||||
return $this->makeRequest(HttpMethod::DELETE, $url);
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_add_deploy_key',
|
||||
description: 'Add an SSH deploy key to a repository'
|
||||
)]
|
||||
public function addDeployKey(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $key,
|
||||
bool $readOnly = true
|
||||
): array {
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
|
||||
|
||||
$data = [
|
||||
'title' => $title,
|
||||
'key' => $key,
|
||||
'read_only' => $readOnly,
|
||||
];
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
|
||||
|
||||
if ($result['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'deploy_key' => [
|
||||
'id' => $result['response']['id'] ?? null,
|
||||
'title' => $result['response']['title'] ?? $title,
|
||||
'key' => $result['response']['key'] ?? $key,
|
||||
'read_only' => $result['response']['read_only'] ?? $readOnly,
|
||||
'created_at' => $result['response']['created_at'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_list_deploy_keys',
|
||||
description: 'List all deploy keys for a repository'
|
||||
)]
|
||||
public function listDeployKeys(string $owner, string $repo): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::GET, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
$keys = array_map(function ($key) {
|
||||
return [
|
||||
'id' => $key['id'] ?? null,
|
||||
'title' => $key['title'] ?? 'unknown',
|
||||
'key' => $key['key'] ?? '',
|
||||
'read_only' => $key['read_only'] ?? true,
|
||||
'created_at' => $key['created_at'] ?? null,
|
||||
];
|
||||
}, $result['response'] ?? []);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'deploy_keys' => $keys,
|
||||
'count' => count($keys),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_delete_deploy_key',
|
||||
description: 'Delete a deploy key from a repository'
|
||||
)]
|
||||
public function deleteDeployKey(string $owner, string $repo, int $keyId): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys/$keyId";
|
||||
|
||||
return $this->makeRequest(HttpMethod::DELETE, $url);
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_add_user_ssh_key',
|
||||
description: 'Add an SSH key to the authenticated user'
|
||||
)]
|
||||
public function addUserSshKey(string $title, string $key, bool $readOnly = false): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/user/keys";
|
||||
|
||||
$data = [
|
||||
'title' => $title,
|
||||
'key' => $key,
|
||||
'read_only' => $readOnly,
|
||||
];
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
|
||||
|
||||
if ($result['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'ssh_key' => [
|
||||
'id' => $result['response']['id'] ?? null,
|
||||
'title' => $result['response']['title'] ?? $title,
|
||||
'key' => $result['response']['key'] ?? $key,
|
||||
'read_only' => $result['response']['read_only'] ?? $readOnly,
|
||||
'created_at' => $result['response']['created_at'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_list_user_ssh_keys',
|
||||
description: 'List all SSH keys for the authenticated user'
|
||||
)]
|
||||
public function listUserSshKeys(): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/user/keys";
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::GET, $url);
|
||||
|
||||
if ($result['success']) {
|
||||
$keys = array_map(function ($key) {
|
||||
return [
|
||||
'id' => $key['id'] ?? null,
|
||||
'title' => $key['title'] ?? 'unknown',
|
||||
'key' => $key['key'] ?? '',
|
||||
'fingerprint' => $key['fingerprint'] ?? '',
|
||||
'read_only' => $key['read_only'] ?? false,
|
||||
'created_at' => $key['created_at'] ?? null,
|
||||
];
|
||||
}, $result['response'] ?? []);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'ssh_keys' => $keys,
|
||||
'count' => count($keys),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_delete_user_ssh_key',
|
||||
description: 'Delete an SSH key from the authenticated user'
|
||||
)]
|
||||
public function deleteUserSshKey(int $keyId): array
|
||||
{
|
||||
$url = "{$this->giteaUrl}/api/v1/user/keys/$keyId";
|
||||
|
||||
return $this->makeRequest(HttpMethod::DELETE, $url);
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_add_remote',
|
||||
description: 'Add Gitea repository as git remote'
|
||||
)]
|
||||
public function addRemote(
|
||||
string $remoteName,
|
||||
string $owner,
|
||||
string $repo,
|
||||
bool $useSsh = true
|
||||
): array {
|
||||
// Get repository info first
|
||||
$repoInfo = $this->getRepository($owner, $repo);
|
||||
|
||||
if (! $repoInfo['success']) {
|
||||
return $repoInfo;
|
||||
}
|
||||
|
||||
$url = $useSsh
|
||||
? $repoInfo['repository']['ssh_url']
|
||||
: $repoInfo['repository']['clone_url'];
|
||||
|
||||
if (! $url) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Repository URL not found',
|
||||
];
|
||||
}
|
||||
|
||||
// Add remote via git command
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
$command = sprintf(
|
||||
'git remote add %s %s 2>&1',
|
||||
escapeshellarg($remoteName),
|
||||
escapeshellarg($url)
|
||||
);
|
||||
exec($command, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
// Check if remote already exists
|
||||
if (str_contains(implode("\n", $output), 'already exists')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Remote already exists',
|
||||
'suggestion' => "Use 'git remote set-url $remoteName $url' to update",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Failed to add remote',
|
||||
'output' => implode("\n", $output),
|
||||
'exit_code' => $exitCode,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'remote_name' => $remoteName,
|
||||
'url' => $url,
|
||||
'use_ssh' => $useSsh,
|
||||
];
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'gitea_webhook_create',
|
||||
description: 'Create a webhook for a repository'
|
||||
)]
|
||||
public function createWebhook(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $url,
|
||||
string $contentType = 'json',
|
||||
array $events = ['push'],
|
||||
bool $active = true,
|
||||
?string $secret = null
|
||||
): array {
|
||||
$hookUrl = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/hooks";
|
||||
|
||||
$data = [
|
||||
'type' => 'gitea',
|
||||
'config' => [
|
||||
'url' => $url,
|
||||
'content_type' => $contentType,
|
||||
'secret' => $secret ?? '',
|
||||
],
|
||||
'events' => $events,
|
||||
'active' => $active,
|
||||
];
|
||||
|
||||
$result = $this->makeRequest(HttpMethod::POST, $hookUrl, $data);
|
||||
|
||||
if ($result['success']) {
|
||||
return [
|
||||
'success' => true,
|
||||
'webhook' => [
|
||||
'id' => $result['response']['id'] ?? null,
|
||||
'url' => $result['response']['config']['url'] ?? $url,
|
||||
'events' => $result['response']['events'] ?? $events,
|
||||
'active' => $result['response']['active'] ?? $active,
|
||||
'created_at' => $result['response']['created_at'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ==================== Private Helper Methods ====================
|
||||
|
||||
private function makeRequest(HttpMethod $method, string $url, ?array $data = null): array
|
||||
{
|
||||
try {
|
||||
$options = [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Basic ' . base64_encode("{$this->giteaUsername}:{$this->giteaPassword}"),
|
||||
],
|
||||
'verify_ssl' => false, // For self-signed certificates
|
||||
];
|
||||
|
||||
if ($data !== null) {
|
||||
$options['json'] = $data;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request($method, $url, $options);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$body = $response->getBody();
|
||||
|
||||
// Decode JSON response
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
if ($statusCode >= 200 && $statusCode < 300) {
|
||||
return [
|
||||
'success' => true,
|
||||
'response' => $decoded,
|
||||
'http_code' => $statusCode,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $decoded['message'] ?? 'HTTP error ' . $statusCode,
|
||||
'response' => $decoded,
|
||||
'http_code' => $statusCode,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Request failed: ' . $e->getMessage(),
|
||||
'exception' => get_class($e),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/Framework/Mcp/Tools/GiteaToolsInitializer.php
Normal file
37
src/Framework/Mcp/Tools/GiteaToolsInitializer.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Mcp\Tools;
|
||||
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\HttpClient\HttpClient;
|
||||
|
||||
/**
|
||||
* Initializer for Gitea MCP Tools
|
||||
*/
|
||||
final readonly class GiteaToolsInitializer
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClient $httpClient,
|
||||
private Environment $environment
|
||||
) {
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function __invoke(): GiteaTools
|
||||
{
|
||||
// Get Gitea configuration from environment
|
||||
$giteaUrl = $this->environment->get('GITEA_URL', 'https://localhost:9443');
|
||||
$giteaUsername = $this->environment->get('GITEA_USERNAME', 'michael');
|
||||
$giteaPassword = $this->environment->get('GITEA_PASSWORD', 'GiteaAdmin2024');
|
||||
|
||||
return new GiteaTools(
|
||||
$this->httpClient,
|
||||
$giteaUrl,
|
||||
$giteaUsername,
|
||||
$giteaPassword
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ namespace App\Framework\Notification\Storage;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\SqlQuery;
|
||||
use App\Framework\Database\ValueObjects\SqlQuery;
|
||||
use App\Framework\DI\Attributes\DefaultImplementation;
|
||||
use App\Framework\Notification\Notification;
|
||||
use App\Framework\Notification\ValueObjects\NotificationChannel;
|
||||
use App\Framework\Notification\ValueObjects\NotificationId;
|
||||
@@ -17,17 +18,16 @@ use App\Framework\Notification\ValueObjects\NotificationType;
|
||||
/**
|
||||
* Database implementation of NotificationRepository
|
||||
*/
|
||||
#[DefaultImplementation]
|
||||
final readonly class DatabaseNotificationRepository implements NotificationRepository
|
||||
{
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function save(Notification $notification): void
|
||||
{
|
||||
$query = new SqlQuery(
|
||||
sql: <<<'SQL'
|
||||
$query = SqlQuery::create(<<<'SQL'
|
||||
INSERT INTO notifications (
|
||||
id, recipient_id, type, title, body, data,
|
||||
channels, priority, status, created_at, sent_at,
|
||||
@@ -38,7 +38,7 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
sent_at = EXCLUDED.sent_at,
|
||||
read_at = EXCLUDED.read_at
|
||||
SQL,
|
||||
params: [
|
||||
[
|
||||
$notification->id->toString(),
|
||||
$notification->recipientId,
|
||||
$notification->type->toString(),
|
||||
@@ -61,9 +61,9 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
|
||||
public function findById(NotificationId $id): ?Notification
|
||||
{
|
||||
$query = new SqlQuery(
|
||||
sql: 'SELECT * FROM notifications WHERE id = ?',
|
||||
params: [$id->toString()]
|
||||
$query = SqlQuery::create(
|
||||
'SELECT * FROM notifications WHERE id = ?',
|
||||
[$id->toString()]
|
||||
);
|
||||
|
||||
$row = $this->connection->queryOne($query);
|
||||
@@ -73,14 +73,14 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
|
||||
public function findByUser(string $userId, int $limit = 20, int $offset = 0): array
|
||||
{
|
||||
$query = new SqlQuery(
|
||||
sql: <<<'SQL'
|
||||
$query = SqlQuery::create(
|
||||
<<<'SQL'
|
||||
SELECT * FROM notifications
|
||||
WHERE recipient_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
SQL,
|
||||
params: [$userId, $limit, $offset]
|
||||
[$userId, $limit, $offset]
|
||||
);
|
||||
|
||||
$rows = $this->connection->query($query)->fetchAll();
|
||||
@@ -90,15 +90,15 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
|
||||
public function findUnreadByUser(string $userId, int $limit = 20): array
|
||||
{
|
||||
$query = new SqlQuery(
|
||||
sql: <<<'SQL'
|
||||
$query = SqlQuery::create(
|
||||
<<<'SQL'
|
||||
SELECT * FROM notifications
|
||||
WHERE recipient_id = ?
|
||||
AND status != ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
SQL,
|
||||
params: [$userId, NotificationStatus::READ->value, $limit]
|
||||
[$userId, NotificationStatus::READ->value, $limit]
|
||||
);
|
||||
|
||||
$rows = $this->connection->query($query)->fetchAll();
|
||||
@@ -108,13 +108,13 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
|
||||
public function countUnreadByUser(string $userId): int
|
||||
{
|
||||
$query = new SqlQuery(
|
||||
sql: <<<'SQL'
|
||||
$query = SqlQuery::create(
|
||||
<<<'SQL'
|
||||
SELECT COUNT(*) as count FROM notifications
|
||||
WHERE recipient_id = ?
|
||||
AND status != ?
|
||||
SQL,
|
||||
params: [$userId, NotificationStatus::READ->value]
|
||||
[$userId, NotificationStatus::READ->value]
|
||||
);
|
||||
|
||||
return (int) $this->connection->queryScalar($query);
|
||||
@@ -122,15 +122,15 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
|
||||
public function markAsRead(NotificationId $id): bool
|
||||
{
|
||||
$query = new SqlQuery(
|
||||
sql: <<<'SQL'
|
||||
$query = SqlQuery::create(
|
||||
<<<'SQL'
|
||||
UPDATE notifications
|
||||
SET status = ?, read_at = ?
|
||||
WHERE id = ?
|
||||
SQL,
|
||||
params: [
|
||||
[
|
||||
NotificationStatus::READ->value,
|
||||
(new Timestamp())->format('Y-m-d H:i:s'),
|
||||
Timestamp::now()->format('Y-m-d H:i:s'),
|
||||
$id->toString(),
|
||||
]
|
||||
);
|
||||
@@ -140,16 +140,16 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
|
||||
public function markAllAsReadForUser(string $userId): int
|
||||
{
|
||||
$query = new SqlQuery(
|
||||
sql: <<<'SQL'
|
||||
$query = SqlQuery::create(
|
||||
<<<'SQL'
|
||||
UPDATE notifications
|
||||
SET status = ?, read_at = ?
|
||||
WHERE recipient_id = ?
|
||||
AND status != ?
|
||||
SQL,
|
||||
params: [
|
||||
[
|
||||
NotificationStatus::READ->value,
|
||||
(new Timestamp())->format('Y-m-d H:i:s'),
|
||||
Timestamp::now()->format('Y-m-d H:i:s'),
|
||||
$userId,
|
||||
NotificationStatus::READ->value,
|
||||
]
|
||||
@@ -160,9 +160,9 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
|
||||
public function delete(NotificationId $id): bool
|
||||
{
|
||||
$query = new SqlQuery(
|
||||
sql: 'DELETE FROM notifications WHERE id = ?',
|
||||
params: [$id->toString()]
|
||||
$query = SqlQuery::create(
|
||||
'DELETE FROM notifications WHERE id = ?',
|
||||
[$id->toString()]
|
||||
);
|
||||
|
||||
return $this->connection->execute($query) > 0;
|
||||
@@ -172,13 +172,13 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
{
|
||||
$cutoffDate = (new Timestamp())->modify("-{$daysOld} days");
|
||||
|
||||
$query = new SqlQuery(
|
||||
sql: <<<'SQL'
|
||||
$query = SqlQuery::create(
|
||||
<<<'SQL'
|
||||
DELETE FROM notifications
|
||||
WHERE status = ?
|
||||
AND created_at < ?
|
||||
SQL,
|
||||
params: [
|
||||
[
|
||||
$status->value,
|
||||
$cutoffDate->format('Y-m-d H:i:s'),
|
||||
]
|
||||
@@ -195,19 +195,19 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
|
||||
);
|
||||
|
||||
return new Notification(
|
||||
id: NotificationId::fromString($row['id']),
|
||||
id : NotificationId::fromString($row['id']),
|
||||
recipientId: $row['recipient_id'],
|
||||
type: NotificationType::fromString($row['type']),
|
||||
title: $row['title'],
|
||||
body: $row['body'],
|
||||
data: json_decode($row['data'], true) ?? [],
|
||||
channels: $channels,
|
||||
priority: NotificationPriority::from($row['priority']),
|
||||
status: NotificationStatus::from($row['status']),
|
||||
createdAt: Timestamp::fromString($row['created_at']),
|
||||
sentAt: $row['sent_at'] ? Timestamp::fromString($row['sent_at']) : null,
|
||||
readAt: $row['read_at'] ? Timestamp::fromString($row['read_at']) : null,
|
||||
actionUrl: $row['action_url'],
|
||||
type : NotificationType::fromString($row['type']),
|
||||
title : $row['title'],
|
||||
body : $row['body'],
|
||||
createdAt : Timestamp::fromTimestamp((int) strtotime($row['created_at'])),
|
||||
data : json_decode($row['data'], true) ?? [],
|
||||
channels : $channels,
|
||||
priority : NotificationPriority::from($row['priority']),
|
||||
status : NotificationStatus::from($row['status']),
|
||||
sentAt : $row['sent_at'] ? Timestamp::fromTimestamp((int) strtotime($row['sent_at'])) : null,
|
||||
readAt : $row['read_at'] ? Timestamp::fromTimestamp((int) strtotime($row['read_at'])) : null,
|
||||
actionUrl : $row['action_url'],
|
||||
actionLabel: $row['action_label']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,10 +45,10 @@ final readonly class TemplateRenderer
|
||||
|
||||
// Create base notification
|
||||
$notification = Notification::create(
|
||||
recipientId: $recipientId,
|
||||
type: $type,
|
||||
title: $title,
|
||||
body: $body,
|
||||
$recipientId,
|
||||
$type,
|
||||
$title,
|
||||
$body,
|
||||
...$channels
|
||||
)->withPriority($template->defaultPriority);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace App\Framework\Notification\ValueObjects;
|
||||
/**
|
||||
* Type/Category of notification for user preferences and filtering
|
||||
*/
|
||||
final readonly class NotificationType
|
||||
final readonly class NotificationType implements NotificationTypeInterface
|
||||
{
|
||||
private function __construct(
|
||||
private string $value
|
||||
@@ -57,4 +57,14 @@ final readonly class NotificationType
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function isCritical(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,17 +16,22 @@ use Throwable;
|
||||
*/
|
||||
final readonly class ExponentialBackoffStrategy implements RetryStrategy
|
||||
{
|
||||
private Duration $initialDelay;
|
||||
private Duration $maxDelay;
|
||||
|
||||
public function __construct(
|
||||
private int $maxAttempts = 3,
|
||||
private Duration $initialDelay = new Duration(100), // 100ms
|
||||
?Duration $initialDelay = null,
|
||||
private float $multiplier = 2.0,
|
||||
private Duration $maxDelay = new Duration(10000), // 10s
|
||||
?Duration $maxDelay = null,
|
||||
private bool $useJitter = true,
|
||||
private array $retryableExceptions = [
|
||||
\RuntimeException::class,
|
||||
\Exception::class,
|
||||
]
|
||||
) {
|
||||
$this->initialDelay = $initialDelay ?? Duration::fromMilliseconds(100);
|
||||
$this->maxDelay = $maxDelay ?? Duration::fromSeconds(10);
|
||||
}
|
||||
|
||||
public function shouldRetry(int $currentAttempt, Throwable $exception): bool
|
||||
|
||||
@@ -35,6 +35,8 @@ enum AdminRoutes: string implements RouteNameInterface
|
||||
case SYSTEM_PHPINFO = 'admin.system.phpinfo';
|
||||
case SYSTEM_ENVIRONMENT = 'admin.system.environment';
|
||||
|
||||
case ML_DASHBOARD = 'admin.ml.dashboard';
|
||||
|
||||
public function getCategory(): RouteCategory
|
||||
{
|
||||
return RouteCategory::ADMIN;
|
||||
|
||||
@@ -17,5 +17,5 @@ interface SerializableState
|
||||
/**
|
||||
* Create state from array (deserialization)
|
||||
*/
|
||||
public static function fromArray(array $data): static;
|
||||
public static function fromArray(array $data): self;
|
||||
}
|
||||
|
||||
330
src/Framework/Template/Expression/ExpressionEvaluator.php
Normal file
330
src/Framework/Template/Expression/ExpressionEvaluator.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Template\Expression;
|
||||
|
||||
/**
|
||||
* Evaluates template expressions with variable resolution and comparison operators
|
||||
*
|
||||
* Supports:
|
||||
* - Simple variables: isLoggedIn, $count
|
||||
* - Comparisons: $count > 0, $status === 'active'
|
||||
* - Negations: !$is_empty, !isAdmin
|
||||
* - Array access: $user['role'], $items[0]
|
||||
* - Object properties: $user->isAdmin, $date->year
|
||||
* - Logical operators: $count > 0 && $enabled, $a || $b
|
||||
*/
|
||||
final readonly class ExpressionEvaluator
|
||||
{
|
||||
/**
|
||||
* Evaluates a conditional expression and returns boolean result
|
||||
*/
|
||||
public function evaluateCondition(string $expression, array $context): bool
|
||||
{
|
||||
$value = $this->evaluate($expression, $context);
|
||||
|
||||
return $this->isTruthy($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates an expression and returns the resolved value
|
||||
*/
|
||||
public function evaluate(string $expression, array $context): mixed
|
||||
{
|
||||
$expression = trim($expression);
|
||||
|
||||
// Handle logical OR (lowest precedence)
|
||||
if (str_contains($expression, '||')) {
|
||||
$parts = $this->splitByOperator($expression, '||');
|
||||
foreach ($parts as $part) {
|
||||
if ($this->evaluateCondition($part, $context)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle logical AND
|
||||
if (str_contains($expression, '&&')) {
|
||||
$parts = $this->splitByOperator($expression, '&&');
|
||||
foreach ($parts as $part) {
|
||||
if (!$this->evaluateCondition($part, $context)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle negation
|
||||
if (str_starts_with($expression, '!')) {
|
||||
$innerExpression = trim(substr($expression, 1));
|
||||
return !$this->evaluateCondition($innerExpression, $context);
|
||||
}
|
||||
|
||||
// Handle comparison operators
|
||||
if (preg_match('/(.+?)\s*(===|!==|==|!=|<=|>=|<|>)\s*(.+)/', $expression, $matches)) {
|
||||
$left = $this->resolveValue(trim($matches[1]), $context);
|
||||
$operator = $matches[2];
|
||||
$right = $this->resolveValue(trim($matches[3]), $context);
|
||||
|
||||
return $this->compareValues($left, $operator, $right);
|
||||
}
|
||||
|
||||
// Simple value resolution
|
||||
return $this->resolveValue($expression, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a value from expression (variable, literal, array access, etc.)
|
||||
*/
|
||||
private function resolveValue(string $expression, array $context): mixed
|
||||
{
|
||||
$expression = trim($expression);
|
||||
|
||||
// String literals with quotes
|
||||
if (preg_match('/^["\'](.*)["\']*$/', $expression, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Numbers
|
||||
if (is_numeric($expression)) {
|
||||
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
|
||||
}
|
||||
|
||||
// Boolean literals
|
||||
if ($expression === 'true') return true;
|
||||
if ($expression === 'false') return false;
|
||||
if ($expression === 'null') return null;
|
||||
|
||||
// Array access: $var['key'] or $var["key"] or $var[0]
|
||||
if (preg_match('/^\$([a-zA-Z_][a-zA-Z0-9_]*)\[(["\']?)([^"\'\]]+)\2\]$/', $expression, $matches)) {
|
||||
$varName = $matches[1];
|
||||
$key = $matches[3];
|
||||
|
||||
if (!array_key_exists($varName, $context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $context[$varName];
|
||||
|
||||
// Numeric key
|
||||
if (is_numeric($key)) {
|
||||
$key = (int) $key;
|
||||
}
|
||||
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
return $value[$key];
|
||||
}
|
||||
|
||||
if (is_object($value) && property_exists($value, $key)) {
|
||||
return $value->$key;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Object property access: $var->property
|
||||
if (preg_match('/^\$([a-zA-Z_][a-zA-Z0-9_]*)->([a-zA-Z_][a-zA-Z0-9_]*)$/', $expression, $matches)) {
|
||||
$varName = $matches[1];
|
||||
$property = $matches[2];
|
||||
|
||||
if (!array_key_exists($varName, $context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $context[$varName];
|
||||
|
||||
if (is_object($value) && property_exists($value, $property)) {
|
||||
return $value->$property;
|
||||
}
|
||||
|
||||
if (is_array($value) && array_key_exists($property, $value)) {
|
||||
return $value[$property];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Simple variable with $: $varName
|
||||
if (preg_match('/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/', $expression, $matches)) {
|
||||
$varName = $matches[1];
|
||||
return array_key_exists($varName, $context) ? $context[$varName] : null;
|
||||
}
|
||||
|
||||
// Dot notation support (backward compatibility): user.isAdmin, items.length
|
||||
if (str_contains($expression, '.')) {
|
||||
// Handle .length for arrays
|
||||
if (str_ends_with($expression, '.length')) {
|
||||
$basePath = substr($expression, 0, -7);
|
||||
$value = $this->resolveDotNotation($context, $basePath);
|
||||
|
||||
if (is_array($value)) {
|
||||
return count($value);
|
||||
}
|
||||
if (is_object($value) && method_exists($value, 'count')) {
|
||||
return $value->count();
|
||||
}
|
||||
if (is_countable($value)) {
|
||||
return count($value);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handle method calls: collection.isEmpty()
|
||||
if (str_contains($expression, '()')) {
|
||||
$methodPos = strpos($expression, '()');
|
||||
$basePath = substr($expression, 0, $methodPos);
|
||||
$methodName = substr($basePath, strrpos($basePath, '.') + 1);
|
||||
$objectPath = substr($basePath, 0, strrpos($basePath, '.'));
|
||||
|
||||
$object = $this->resolveDotNotation($context, $objectPath);
|
||||
if (is_object($object) && method_exists($object, $methodName)) {
|
||||
return $object->$methodName();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard dot notation
|
||||
return $this->resolveDotNotation($context, $expression);
|
||||
}
|
||||
|
||||
// Simple variable without $: varName (for backward compatibility)
|
||||
if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $expression)) {
|
||||
return array_key_exists($expression, $context) ? $context[$expression] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves nested property paths using dot notation (e.g., "user.name", "items.0")
|
||||
*/
|
||||
private function resolveDotNotation(array $data, string $path): mixed
|
||||
{
|
||||
$keys = explode('.', $path);
|
||||
$value = $data;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
} elseif (is_object($value) && isset($value->$key)) {
|
||||
$value = $value->$key;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits expression by operator, respecting quotes and parentheses
|
||||
*/
|
||||
private function splitByOperator(string $expression, string $operator): array
|
||||
{
|
||||
$parts = [];
|
||||
$current = '';
|
||||
$depth = 0;
|
||||
$inQuotes = false;
|
||||
$quoteChar = null;
|
||||
$len = strlen($expression);
|
||||
$opLen = strlen($operator);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$char = $expression[$i];
|
||||
|
||||
// Handle quotes
|
||||
if (!$inQuotes && ($char === '"' || $char === "'")) {
|
||||
$inQuotes = true;
|
||||
$quoteChar = $char;
|
||||
$current .= $char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inQuotes && $char === $quoteChar && ($i === 0 || $expression[$i-1] !== '\\')) {
|
||||
$inQuotes = false;
|
||||
$quoteChar = null;
|
||||
$current .= $char;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle parentheses depth
|
||||
if (!$inQuotes) {
|
||||
if ($char === '(') {
|
||||
$depth++;
|
||||
} elseif ($char === ')') {
|
||||
$depth--;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for operator at depth 0 and not in quotes
|
||||
if (!$inQuotes && $depth === 0 && substr($expression, $i, $opLen) === $operator) {
|
||||
$parts[] = trim($current);
|
||||
$current = '';
|
||||
$i += $opLen - 1; // Skip operator
|
||||
continue;
|
||||
}
|
||||
|
||||
$current .= $char;
|
||||
}
|
||||
|
||||
if ($current !== '') {
|
||||
$parts[] = trim($current);
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two values using an operator
|
||||
*/
|
||||
private function compareValues(mixed $left, string $operator, mixed $right): bool
|
||||
{
|
||||
return match ($operator) {
|
||||
'===' => $left === $right,
|
||||
'!==' => $left !== $right,
|
||||
'==' => $left == $right,
|
||||
'!=' => $left != $right,
|
||||
'<' => $left < $right,
|
||||
'>' => $left > $right,
|
||||
'<=' => $left <= $right,
|
||||
'>=' => $left >= $right,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a value is truthy
|
||||
*/
|
||||
private function isTruthy(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_null($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return trim($value) !== '';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return $value != 0;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return count($value) > 0;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $value;
|
||||
}
|
||||
}
|
||||
171
src/Framework/Template/Expression/PlaceholderProcessor.php
Normal file
171
src/Framework/Template/Expression/PlaceholderProcessor.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Template\Expression;
|
||||
|
||||
use App\Framework\View\RawHtml;
|
||||
|
||||
/**
|
||||
* PlaceholderProcessor - Processes template placeholders using ExpressionEvaluator
|
||||
*
|
||||
* Replaces placeholders like {{ $var }}, {{ $var['key'] }}, {{ $obj->prop }} with actual values
|
||||
* Uses ExpressionEvaluator for consistent expression evaluation across the framework
|
||||
*
|
||||
* Supports:
|
||||
* - Simple variables: {{ $name }}, {{ name }}
|
||||
* - Array access: {{ $user['email'] }}, {{ $items[0] }}
|
||||
* - Object properties: {{ $user->name }}, {{ $date->format }}
|
||||
* - Dot notation: {{ user.name }}, {{ items.0 }}
|
||||
* - Expressions: {{ $count > 0 }}, {{ $user->isAdmin }}
|
||||
*
|
||||
* Framework Pattern: readonly class, composition with ExpressionEvaluator
|
||||
*/
|
||||
final readonly class PlaceholderProcessor
|
||||
{
|
||||
private ExpressionEvaluator $evaluator;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->evaluator = new ExpressionEvaluator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all placeholders in HTML content
|
||||
*
|
||||
* @param string $html HTML content with placeholders
|
||||
* @param array $context Variable context
|
||||
* @return string HTML with replaced placeholders
|
||||
*/
|
||||
public function process(string $html, array $context): string
|
||||
{
|
||||
// Pattern matches {{ expression }} with optional whitespace
|
||||
$pattern = '/{{\\s*(.+?)\\s*}}/';
|
||||
|
||||
return preg_replace_callback(
|
||||
$pattern,
|
||||
function ($matches) use ($context) {
|
||||
$expression = $matches[1];
|
||||
|
||||
// Evaluate expression using ExpressionEvaluator
|
||||
$value = $this->evaluator->evaluate($expression, $context);
|
||||
|
||||
// Format value for HTML output
|
||||
return $this->formatValue($value);
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace placeholders for a specific loop variable
|
||||
*
|
||||
* Useful in foreach loops where we want to replace only the loop variable placeholders
|
||||
* and leave other placeholders for later processing
|
||||
*
|
||||
* @param string $html HTML content
|
||||
* @param string $varName Loop variable name (without $)
|
||||
* @param mixed $item Loop item value
|
||||
* @return string HTML with loop variable placeholders replaced
|
||||
*/
|
||||
public function processLoopVariable(string $html, string $varName, mixed $item): string
|
||||
{
|
||||
// Pattern 1: Array access {{ $varName['property'] }} or {{ varName['property'] }} ($ optional)
|
||||
// We need to handle both escaped (", ') and unescaped quotes
|
||||
$arrayPatternDouble = '/{{\\s*\\$?' . preg_quote($varName, '/') . '\\[(?:"|")([^"&]+?)(?:"|")\\]\\s*}}/';
|
||||
$arrayPatternSingle = '/{{\\s*\\$?' . preg_quote($varName, '/') . '\\[(?:'|\')([^\'&]+?)(?:'|\')\\]\\s*}}/';
|
||||
|
||||
// Pattern 2: Object property {{ $varName->property }} or {{ varName->property }} ($ optional)
|
||||
$objectPattern = '/{{\\s*\\$?' . preg_quote($varName, '/') . '->([\\w]+)\\s*}}/';
|
||||
|
||||
// Pattern 3: Dot notation {{ varName.property }} or {{ $varName.property }} ($ already optional)
|
||||
$dotPattern = '/{{\\s*\\$?' . preg_quote($varName, '/') . '\\.([\\w]+)\\s*}}/';
|
||||
|
||||
// Pattern 4: Simple variable {{ $varName }} or {{ varName }} ($ optional)
|
||||
$simplePattern = '/{{\\s*\\$?' . preg_quote($varName, '/') . '\\s*}}/';
|
||||
|
||||
// Replace in order: array access (double quotes), array access (single quotes), object property, dot notation, simple variable
|
||||
$html = preg_replace_callback(
|
||||
$arrayPatternDouble,
|
||||
function($matches) use ($item) {
|
||||
return $this->formatValue($this->getProperty($item, $matches[1]));
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
$html = preg_replace_callback(
|
||||
$arrayPatternSingle,
|
||||
function($matches) use ($item) {
|
||||
return $this->formatValue($this->getProperty($item, $matches[1]));
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
$html = preg_replace_callback(
|
||||
$objectPattern,
|
||||
function($matches) use ($item) {
|
||||
return $this->formatValue($this->getProperty($item, $matches[1]));
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
$html = preg_replace_callback(
|
||||
$dotPattern,
|
||||
function($matches) use ($item) {
|
||||
return $this->formatValue($this->getProperty($item, $matches[1]));
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
$html = preg_replace_callback(
|
||||
$simplePattern,
|
||||
function($matches) use ($item) {
|
||||
return $this->formatValue($item);
|
||||
},
|
||||
$html
|
||||
);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get property value from item (array or object)
|
||||
*/
|
||||
private function getProperty(mixed $item, string $property): mixed
|
||||
{
|
||||
if (is_array($item) && array_key_exists($property, $item)) {
|
||||
return $item[$property];
|
||||
}
|
||||
|
||||
if (is_object($item) && isset($item->$property)) {
|
||||
return $item->$property;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format value for HTML output
|
||||
*/
|
||||
private function formatValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if ($value instanceof RawHtml) {
|
||||
return $value->content;
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
// Don't render complex types, return empty string
|
||||
return '';
|
||||
}
|
||||
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Version;
|
||||
use App\Framework\UserAgent\Enums\BrowserType;
|
||||
use App\Framework\UserAgent\Enums\EngineType;
|
||||
use App\Framework\UserAgent\Enums\PlatformType;
|
||||
use App\Framework\UserAgent\ValueObjects\DeviceCategory;
|
||||
|
||||
/**
|
||||
* Value Object representing a parsed User-Agent with rich metadata
|
||||
@@ -17,11 +19,11 @@ final readonly class ParsedUserAgent
|
||||
public function __construct(
|
||||
public string $raw,
|
||||
public BrowserType $browser,
|
||||
public string $browserVersion,
|
||||
public Version $browserVersion,
|
||||
public PlatformType $platform,
|
||||
public string $platformVersion,
|
||||
public Version $platformVersion,
|
||||
public EngineType $engine,
|
||||
public string $engineVersion,
|
||||
public Version $engineVersion,
|
||||
public bool $isMobile,
|
||||
public bool $isBot,
|
||||
public bool $isModern
|
||||
@@ -41,11 +43,7 @@ final readonly class ParsedUserAgent
|
||||
*/
|
||||
public function getBrowserName(): string
|
||||
{
|
||||
if ($this->browserVersion === 'Unknown') {
|
||||
return $this->browser->getDisplayName();
|
||||
}
|
||||
|
||||
return $this->browser->getDisplayName() . ' ' . $this->browserVersion;
|
||||
return $this->browser->getDisplayName() . ' ' . $this->browserVersion->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,11 +51,7 @@ final readonly class ParsedUserAgent
|
||||
*/
|
||||
public function getPlatformName(): string
|
||||
{
|
||||
if ($this->platformVersion === 'Unknown') {
|
||||
return $this->platform->getDisplayName();
|
||||
}
|
||||
|
||||
return $this->platform->getDisplayName() . ' ' . $this->platformVersion;
|
||||
return $this->platform->getDisplayName() . ' ' . $this->platformVersion->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,11 +59,7 @@ final readonly class ParsedUserAgent
|
||||
*/
|
||||
public function getEngineName(): string
|
||||
{
|
||||
if ($this->engineVersion === 'Unknown') {
|
||||
return $this->engine->getDisplayName();
|
||||
}
|
||||
|
||||
return $this->engine->getDisplayName() . ' ' . $this->engineVersion;
|
||||
return $this->engine->getDisplayName() . ' ' . $this->engineVersion->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,16 +94,18 @@ final readonly class ParsedUserAgent
|
||||
return match ($feature) {
|
||||
// Image formats
|
||||
'webp' => $this->browser->getEngine() === EngineType::BLINK ||
|
||||
($this->browser === BrowserType::FIREFOX && version_compare($this->browserVersion, '65.0', '>=')),
|
||||
($this->browser === BrowserType::FIREFOX &&
|
||||
$this->browserVersion->isNewerThan(Version::fromString('65.0')) ||
|
||||
$this->browserVersion->equals(Version::fromString('65.0'))),
|
||||
'avif' => $this->browser->getEngine() === EngineType::BLINK &&
|
||||
version_compare($this->browserVersion, '85.0', '>='),
|
||||
($this->browserVersion->isNewerThan(Version::fromString('85.0')) ||
|
||||
$this->browserVersion->equals(Version::fromString('85.0'))),
|
||||
|
||||
// JavaScript features
|
||||
'es6', 'css-custom-properties', 'css-flexbox', 'css-grid', 'webrtc', 'websockets' => $this->isModern,
|
||||
'es2017' => $this->isModern && version_compare($this->browserVersion, $this->getEs2017MinVersion(), '>='),
|
||||
'es2020' => $this->isModern && version_compare($this->browserVersion, $this->getEs2020MinVersion(), '>='),
|
||||
'es2017' => $this->isModern && $this->supportsEs2017(),
|
||||
'es2020' => $this->isModern && $this->supportsEs2020(),
|
||||
|
||||
// CSS features
|
||||
// Web APIs
|
||||
'service-worker' => $this->isModern && $this->platform !== PlatformType::IOS,
|
||||
'web-push' => $this->isModern && $this->browser !== BrowserType::SAFARI,
|
||||
@@ -122,54 +114,80 @@ final readonly class ParsedUserAgent
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser supports ES2017
|
||||
*/
|
||||
private function supportsEs2017(): bool
|
||||
{
|
||||
$minVersion = $this->getEs2017MinVersion();
|
||||
|
||||
return $this->browserVersion->isNewerThan($minVersion) ||
|
||||
$this->browserVersion->equals($minVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser supports ES2020
|
||||
*/
|
||||
private function supportsEs2020(): bool
|
||||
{
|
||||
$minVersion = $this->getEs2020MinVersion();
|
||||
|
||||
return $this->browserVersion->isNewerThan($minVersion) ||
|
||||
$this->browserVersion->equals($minVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum browser version for ES2017 support
|
||||
*/
|
||||
private function getEs2017MinVersion(): string
|
||||
private function getEs2017MinVersion(): Version
|
||||
{
|
||||
return match ($this->browser) {
|
||||
BrowserType::CHROME => '58.0',
|
||||
BrowserType::FIREFOX => '52.0',
|
||||
BrowserType::SAFARI => '10.1',
|
||||
BrowserType::EDGE => '79.0',
|
||||
BrowserType::OPERA => '45.0',
|
||||
default => '999.0'
|
||||
$versionString = match ($this->browser) {
|
||||
BrowserType::CHROME => '58.0.0',
|
||||
BrowserType::FIREFOX => '52.0.0',
|
||||
BrowserType::SAFARI => '10.1.0',
|
||||
BrowserType::EDGE => '79.0.0',
|
||||
BrowserType::OPERA => '45.0.0',
|
||||
default => '999.0.0'
|
||||
};
|
||||
|
||||
return Version::fromString($versionString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum browser version for ES2020 support
|
||||
*/
|
||||
private function getEs2020MinVersion(): string
|
||||
private function getEs2020MinVersion(): Version
|
||||
{
|
||||
return match ($this->browser) {
|
||||
BrowserType::CHROME => '80.0',
|
||||
BrowserType::FIREFOX => '72.0',
|
||||
BrowserType::SAFARI => '13.1',
|
||||
BrowserType::EDGE => '80.0',
|
||||
BrowserType::OPERA => '67.0',
|
||||
default => '999.0'
|
||||
$versionString = match ($this->browser) {
|
||||
BrowserType::CHROME => '80.0.0',
|
||||
BrowserType::FIREFOX => '72.0.0',
|
||||
BrowserType::SAFARI => '13.1.0',
|
||||
BrowserType::EDGE => '80.0.0',
|
||||
BrowserType::OPERA => '67.0.0',
|
||||
default => '999.0.0'
|
||||
};
|
||||
|
||||
return Version::fromString($versionString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device category
|
||||
*/
|
||||
public function getDeviceCategory(): string
|
||||
public function getDeviceCategory(): DeviceCategory
|
||||
{
|
||||
if ($this->isBot) {
|
||||
return 'bot';
|
||||
return DeviceCategory::BOT;
|
||||
}
|
||||
|
||||
if ($this->platform->isMobile()) {
|
||||
return 'mobile';
|
||||
return DeviceCategory::MOBILE;
|
||||
}
|
||||
|
||||
if ($this->platform->isDesktop()) {
|
||||
return 'desktop';
|
||||
return DeviceCategory::DESKTOP;
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
return DeviceCategory::UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,20 +201,20 @@ final readonly class ParsedUserAgent
|
||||
'browser' => [
|
||||
'type' => $this->browser->value,
|
||||
'name' => $this->browser->getDisplayName(),
|
||||
'version' => $this->browserVersion,
|
||||
'version' => $this->browserVersion->toString(),
|
||||
'fullName' => $this->getBrowserName(),
|
||||
],
|
||||
'platform' => [
|
||||
'type' => $this->platform->value,
|
||||
'name' => $this->platform->getDisplayName(),
|
||||
'version' => $this->platformVersion,
|
||||
'version' => $this->platformVersion->toString(),
|
||||
'fullName' => $this->getPlatformName(),
|
||||
'family' => $this->platform->getFamily(),
|
||||
],
|
||||
'engine' => [
|
||||
'type' => $this->engine->value,
|
||||
'name' => $this->engine->getDisplayName(),
|
||||
'version' => $this->engineVersion,
|
||||
'version' => $this->engineVersion->toString(),
|
||||
'fullName' => $this->getEngineName(),
|
||||
'developer' => $this->engine->getDeveloper(),
|
||||
],
|
||||
@@ -205,7 +223,7 @@ final readonly class ParsedUserAgent
|
||||
'isBot' => $this->isBot,
|
||||
'isModern' => $this->isModern,
|
||||
],
|
||||
'deviceCategory' => $this->getDeviceCategory(),
|
||||
'deviceCategory' => $this->getDeviceCategory()->value,
|
||||
'summary' => $this->getSummary(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Framework\UserAgent;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Hash;
|
||||
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
||||
use App\Framework\Core\ValueObjects\Version;
|
||||
use App\Framework\UserAgent\Enums\BrowserType;
|
||||
use App\Framework\UserAgent\Enums\EngineType;
|
||||
use App\Framework\UserAgent\Enums\PlatformType;
|
||||
@@ -20,7 +25,8 @@ final readonly class UserAgentParser
|
||||
{
|
||||
public function __construct(
|
||||
private ?Cache $cache = null
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse User-Agent string into structured ParsedUserAgent object
|
||||
@@ -34,8 +40,9 @@ final readonly class UserAgentParser
|
||||
return $this->createUnknownUserAgent('');
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
$cacheKey = 'useragent:' . md5($normalized);
|
||||
// Check cache first (using framework's Hash VO with fast algorithm)
|
||||
$hash = Hash::create($normalized, HashAlgorithm::fast());
|
||||
$cacheKey = CacheKey::fromString('useragent:' . $hash->toString());
|
||||
if ($this->cache) {
|
||||
$cached = $this->cache->get($cacheKey);
|
||||
if ($cached instanceof ParsedUserAgent) {
|
||||
@@ -67,9 +74,9 @@ final readonly class UserAgentParser
|
||||
isModern: $isModern
|
||||
);
|
||||
|
||||
// Cache result
|
||||
// Cache result for 1 hour
|
||||
if ($this->cache) {
|
||||
$this->cache->set($cacheKey, $parsedUserAgent, 3600); // Cache for 1 hour
|
||||
$this->cache->set($cacheKey, $parsedUserAgent, Duration::fromHours(1));
|
||||
}
|
||||
|
||||
return $parsedUserAgent;
|
||||
@@ -99,16 +106,18 @@ final readonly class UserAgentParser
|
||||
/**
|
||||
* Parse browser version
|
||||
*/
|
||||
private function parseBrowserVersion(string $userAgent, BrowserType $browser): string
|
||||
private function parseBrowserVersion(string $userAgent, BrowserType $browser): Version
|
||||
{
|
||||
// Find matching pattern for this browser
|
||||
foreach (BrowserPatterns::getPatterns() as $pattern) {
|
||||
if ($pattern['browser'] === $browser && preg_match($pattern['versionPattern'], $userAgent, $matches)) {
|
||||
return $matches[1] ?? 'Unknown';
|
||||
$versionString = $matches[1] ?? '0.0.0';
|
||||
|
||||
return $this->parseVersion($versionString);
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
return Version::fromString('0.0.0');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,25 +137,27 @@ final readonly class UserAgentParser
|
||||
/**
|
||||
* Parse platform version
|
||||
*/
|
||||
private function parsePlatformVersion(string $userAgent, PlatformType $platform): string
|
||||
private function parsePlatformVersion(string $userAgent, PlatformType $platform): Version
|
||||
{
|
||||
foreach (PlatformPatterns::getPatterns() as $pattern) {
|
||||
if ($pattern['platform'] === $platform &&
|
||||
! empty($pattern['versionPattern']) &&
|
||||
preg_match($pattern['versionPattern'], $userAgent, $matches)) {
|
||||
|
||||
$version = $matches[1] ?? 'Unknown';
|
||||
$version = $matches[1] ?? '0.0.0';
|
||||
|
||||
// Format version based on platform
|
||||
return match ($platform) {
|
||||
$formattedVersion = match ($platform) {
|
||||
PlatformType::WINDOWS => PlatformPatterns::formatWindowsVersion($version),
|
||||
PlatformType::MACOS, PlatformType::IOS => PlatformPatterns::formatAppleVersion($version),
|
||||
default => $version
|
||||
};
|
||||
|
||||
return $this->parseVersion($formattedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
return Version::fromString('0.0.0');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,30 +181,32 @@ final readonly class UserAgentParser
|
||||
/**
|
||||
* Parse engine version
|
||||
*/
|
||||
private function parseEngineVersion(string $userAgent, EngineType $engine): string
|
||||
private function parseEngineVersion(string $userAgent, EngineType $engine): Version
|
||||
{
|
||||
foreach (EnginePatterns::getPatterns() as $pattern) {
|
||||
if ($pattern['engine'] === $engine && preg_match($pattern['versionPattern'], $userAgent, $matches)) {
|
||||
$version = $matches[1] ?? 'Unknown';
|
||||
$version = $matches[1] ?? '0.0.0';
|
||||
|
||||
// Special formatting for Gecko
|
||||
if ($engine === EngineType::GECKO) {
|
||||
return EnginePatterns::formatGeckoVersion($version);
|
||||
$formattedVersion = EnginePatterns::formatGeckoVersion($version);
|
||||
|
||||
return $this->parseVersion($formattedVersion);
|
||||
}
|
||||
|
||||
return $version;
|
||||
return $this->parseVersion($version);
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
return Version::fromString('0.0.0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if browser is considered modern
|
||||
*/
|
||||
private function determineModernBrowser(BrowserType $browser, string $version, bool $isBot): bool
|
||||
private function determineModernBrowser(BrowserType $browser, Version $version, bool $isBot): bool
|
||||
{
|
||||
if ($isBot || $version === 'Unknown') {
|
||||
if ($isBot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -201,9 +214,9 @@ final readonly class UserAgentParser
|
||||
return false;
|
||||
}
|
||||
|
||||
$threshold = $browser->getModernVersionThreshold();
|
||||
$threshold = Version::fromString($browser->getModernVersionThreshold());
|
||||
|
||||
return version_compare($version, $threshold, '>=');
|
||||
return $version->isNewerThan($threshold) || $version->equals($threshold);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,17 +227,48 @@ final readonly class UserAgentParser
|
||||
return new ParsedUserAgent(
|
||||
raw: $raw,
|
||||
browser: BrowserType::UNKNOWN,
|
||||
browserVersion: 'Unknown',
|
||||
browserVersion: Version::fromString('0.0.0'),
|
||||
platform: PlatformType::UNKNOWN,
|
||||
platformVersion: 'Unknown',
|
||||
platformVersion: Version::fromString('0.0.0'),
|
||||
engine: EngineType::UNKNOWN,
|
||||
engineVersion: 'Unknown',
|
||||
engineVersion: Version::fromString('0.0.0'),
|
||||
isMobile: false,
|
||||
isBot: false,
|
||||
isModern: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version string into Version Value Object
|
||||
* Handles various version formats from User-Agent strings
|
||||
*/
|
||||
private function parseVersion(string $versionString): Version
|
||||
{
|
||||
// Normalize version string
|
||||
$normalized = trim($versionString);
|
||||
|
||||
if ($normalized === '' || $normalized === 'Unknown') {
|
||||
return Version::fromString('0.0.0');
|
||||
}
|
||||
|
||||
// Try to parse as semver
|
||||
try {
|
||||
return Version::fromString($normalized);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
// If parsing fails, try to extract major.minor.patch from string
|
||||
if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/', $normalized, $matches)) {
|
||||
$major = (int) $matches[1];
|
||||
$minor = isset($matches[2]) ? (int) $matches[2] : 0;
|
||||
$patch = isset($matches[3]) ? (int) $matches[3] : 0;
|
||||
|
||||
return Version::fromComponents($major, $minor, $patch);
|
||||
}
|
||||
|
||||
// Fallback to 0.0.0 if we can't parse
|
||||
return Version::fromString('0.0.0');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear parser cache
|
||||
*/
|
||||
|
||||
58
src/Framework/UserAgent/ValueObjects/DeviceCategory.php
Normal file
58
src/Framework/UserAgent/ValueObjects/DeviceCategory.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\UserAgent\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object representing device category
|
||||
*/
|
||||
enum DeviceCategory: string
|
||||
{
|
||||
case BOT = 'bot';
|
||||
case MOBILE = 'mobile';
|
||||
case DESKTOP = 'desktop';
|
||||
case TABLET = 'tablet';
|
||||
case UNKNOWN = 'unknown';
|
||||
|
||||
/**
|
||||
* Get human-readable display name
|
||||
*/
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::BOT => 'Bot',
|
||||
self::MOBILE => 'Mobile Device',
|
||||
self::DESKTOP => 'Desktop Computer',
|
||||
self::TABLET => 'Tablet',
|
||||
self::UNKNOWN => 'Unknown Device',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is mobile (includes tablets)
|
||||
*/
|
||||
public function isMobile(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::MOBILE, self::TABLET => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is desktop
|
||||
*/
|
||||
public function isDesktop(): bool
|
||||
{
|
||||
return $this === self::DESKTOP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is a bot
|
||||
*/
|
||||
public function isBot(): bool
|
||||
{
|
||||
return $this === self::BOT;
|
||||
}
|
||||
}
|
||||
238
src/Framework/View/Dom/Transformer/ForTransformer.php
Normal file
238
src/Framework/View/Dom/Transformer/ForTransformer.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Dom\Transformer;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Template\Expression\PlaceholderProcessor;
|
||||
use App\Framework\Template\Processing\AstTransformer;
|
||||
use App\Framework\View\Dom\DocumentNode;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
use App\Framework\View\Dom\Node;
|
||||
use App\Framework\View\Dom\TextNode;
|
||||
use App\Framework\View\Processors\PlaceholderReplacer;
|
||||
use App\Framework\View\RenderContext;
|
||||
|
||||
/**
|
||||
* ForTransformer - AST-based foreach loop processor using PlaceholderProcessor
|
||||
*
|
||||
* Processes:
|
||||
* - foreach attributes: <div foreach="$items as $item">
|
||||
* - <for> elements: <for items="items" as="item">
|
||||
*
|
||||
* Uses PlaceholderProcessor for consistent placeholder replacement with ExpressionEvaluator
|
||||
*/
|
||||
final readonly class ForTransformer implements AstTransformer
|
||||
{
|
||||
private PlaceholderProcessor $placeholderProcessor;
|
||||
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
$this->placeholderProcessor = new PlaceholderProcessor();
|
||||
}
|
||||
|
||||
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
|
||||
{
|
||||
$this->processForLoops($document, $context);
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function processForLoops(Node $node, RenderContext $context): void
|
||||
{
|
||||
if (!$node instanceof ElementNode && !$node instanceof DocumentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process children first (depth-first for nested loops)
|
||||
$children = $node->getChildren();
|
||||
foreach ($children as $child) {
|
||||
$this->processForLoops($child, $context);
|
||||
}
|
||||
|
||||
// Process foreach attribute on this element
|
||||
if ($node instanceof ElementNode && $node->hasAttribute('foreach')) {
|
||||
$this->processForeachAttribute($node, $context);
|
||||
}
|
||||
|
||||
// Process <for> elements
|
||||
if ($node instanceof ElementNode && $node->getTagName() === 'for') {
|
||||
$this->processForElement($node, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process foreach attribute: <div foreach="$items as $item">
|
||||
*/
|
||||
private function processForeachAttribute(ElementNode $node, RenderContext $context): void
|
||||
{
|
||||
$foreachExpr = $node->getAttribute('foreach');
|
||||
|
||||
// Parse "array as var" syntax (with or without $ prefix)
|
||||
if (!preg_match('/^\$?(\w+)\s+as\s+\$?(\w+)$/', $foreachExpr, $matches)) {
|
||||
return; // Invalid syntax
|
||||
}
|
||||
|
||||
$dataKey = $matches[1];
|
||||
$varName = $matches[2];
|
||||
|
||||
// Remove foreach attribute
|
||||
$node->removeAttribute('foreach');
|
||||
|
||||
// Resolve items from context
|
||||
$items = $this->resolveValue($context->data, $dataKey);
|
||||
|
||||
if (!is_iterable($items)) {
|
||||
// Remove element if not iterable
|
||||
$parent = $node->getParent();
|
||||
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
|
||||
$parent->removeChild($node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get parent and position
|
||||
$parent = $node->getParent();
|
||||
if (!($parent instanceof ElementNode || $parent instanceof DocumentNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone and process for each item
|
||||
$fragments = [];
|
||||
foreach ($items as $item) {
|
||||
$clone = $node->clone();
|
||||
|
||||
// Process placeholders in cloned element
|
||||
$this->replacePlaceholdersInNode($clone, $varName, $item);
|
||||
|
||||
$fragments[] = $clone;
|
||||
}
|
||||
|
||||
// Replace original node with all fragments
|
||||
$parent->removeChild($node);
|
||||
|
||||
foreach ($fragments as $fragment) {
|
||||
$parent->appendChild($fragment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process <for> element: <for items="items" as="item">
|
||||
*/
|
||||
private function processForElement(ElementNode $node, RenderContext $context): void
|
||||
{
|
||||
// Support both syntaxes
|
||||
$dataKey = $node->getAttribute('items') ?? $node->getAttribute('in');
|
||||
$varName = $node->getAttribute('as') ?? $node->getAttribute('var');
|
||||
|
||||
if (!$dataKey || !$varName) {
|
||||
return; // Invalid syntax
|
||||
}
|
||||
|
||||
// Resolve items from context
|
||||
$items = $this->resolveValue($context->data, $dataKey);
|
||||
|
||||
if (!is_iterable($items)) {
|
||||
// Remove element if not iterable
|
||||
$parent = $node->getParent();
|
||||
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
|
||||
$parent->removeChild($node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get parent and position
|
||||
$parent = $node->getParent();
|
||||
if (!($parent instanceof ElementNode || $parent instanceof DocumentNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process children for each item
|
||||
$fragments = [];
|
||||
foreach ($items as $item) {
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$clone = $child->clone();
|
||||
|
||||
// Process placeholders
|
||||
$this->replacePlaceholdersInNode($clone, $varName, $item);
|
||||
|
||||
$fragments[] = $clone;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace <for> element with processed fragments
|
||||
$parent->removeChild($node);
|
||||
|
||||
foreach ($fragments as $fragment) {
|
||||
$parent->appendChild($fragment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace placeholders in a node and its children using PlaceholderProcessor
|
||||
*/
|
||||
private function replacePlaceholdersInNode(Node $node, string $varName, mixed $item): void
|
||||
{
|
||||
if ($node instanceof TextNode) {
|
||||
// Process text content with PlaceholderProcessor
|
||||
$node->setText(
|
||||
$this->placeholderProcessor->processLoopVariable($node->getTextContent(), $varName, $item)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($node instanceof ElementNode) {
|
||||
// Process attributes - HTML decode first to handle entity-encoded quotes
|
||||
foreach (array_keys($node->getAttributes()) as $attrName) {
|
||||
$attrValue = $node->getAttribute($attrName);
|
||||
if ($attrValue !== null) {
|
||||
// Decode HTML entities (' -> ', " -> ")
|
||||
$decodedValue = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Process placeholders with decoded value
|
||||
$processedValue = $this->placeholderProcessor->processLoopVariable($decodedValue, $varName, $item);
|
||||
|
||||
// Set the processed value (will be re-encoded if needed during rendering)
|
||||
$node->setAttribute($attrName, $processedValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$this->replacePlaceholdersInNode($child, $varName, $item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve nested property paths like "redis.key_sample"
|
||||
*/
|
||||
private function resolveValue(array $data, string $expr): mixed
|
||||
{
|
||||
$keys = explode('.', $expr);
|
||||
$value = $data;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
} elseif (is_object($value)) {
|
||||
if (isset($value->$key)) {
|
||||
$value = $value->$key;
|
||||
} elseif (method_exists($value, $key)) {
|
||||
$value = $value->$key();
|
||||
} elseif (method_exists($value, 'get' . ucfirst($key))) {
|
||||
$getterMethod = 'get' . ucfirst($key);
|
||||
$value = $value->$getterMethod();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\View\Dom\Transformer;
|
||||
|
||||
use App\Framework\Template\Expression\ExpressionEvaluator;
|
||||
use App\Framework\Template\Processing\AstTransformer;
|
||||
use App\Framework\View\Dom\DocumentNode;
|
||||
use App\Framework\View\Dom\ElementNode;
|
||||
@@ -19,17 +20,24 @@ use App\Framework\View\RenderContext;
|
||||
* - Removes attribute if condition is truthy
|
||||
*
|
||||
* Supports:
|
||||
* - Simple properties: if="user.isAdmin"
|
||||
* - Dollar syntax: if="$count > 0", if="$user->isAdmin"
|
||||
* - Dot notation (legacy): if="user.isAdmin", if="items.length > 0"
|
||||
* - Comparisons: if="count > 5", if="status == 'active'"
|
||||
* - Logical operators: if="user.isAdmin && user.isVerified"
|
||||
* - Negation: if="!user.isBanned"
|
||||
* - Array properties: if="items.length > 0"
|
||||
* - Negation: if="!$user->isBanned", if="!user.isAdmin"
|
||||
* - Array access: if="$user['role'] === 'admin'"
|
||||
* - Method calls: if="collection.isEmpty()"
|
||||
*
|
||||
* Framework Pattern: readonly class, AST-based transformation
|
||||
* Framework Pattern: readonly class, AST-based transformation, composition with ExpressionEvaluator
|
||||
*/
|
||||
final readonly class IfTransformer implements AstTransformer
|
||||
{
|
||||
private ExpressionEvaluator $evaluator;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->evaluator = new ExpressionEvaluator();
|
||||
}
|
||||
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
|
||||
{
|
||||
// Process both 'if' and 'condition' attributes
|
||||
@@ -81,180 +89,10 @@ final readonly class IfTransformer implements AstTransformer
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates condition expression with support for operators
|
||||
* Evaluates condition expression using ExpressionEvaluator
|
||||
*/
|
||||
private function evaluateCondition(array $data, string $condition): bool
|
||||
{
|
||||
$condition = trim($condition);
|
||||
|
||||
// Handle logical operators (&&, ||)
|
||||
if (str_contains($condition, '&&')) {
|
||||
$parts = array_map('trim', explode('&&', $condition));
|
||||
foreach ($parts as $part) {
|
||||
if (! $this->evaluateCondition($data, $part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_contains($condition, '||')) {
|
||||
$parts = array_map('trim', explode('||', $condition));
|
||||
foreach ($parts as $part) {
|
||||
if ($this->evaluateCondition($data, $part)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle negation (!)
|
||||
if (str_starts_with($condition, '!')) {
|
||||
$negatedCondition = trim(substr($condition, 1));
|
||||
return ! $this->evaluateCondition($data, $negatedCondition);
|
||||
}
|
||||
|
||||
// Handle comparison operators
|
||||
foreach (['!=', '==', '>=', '<=', '>', '<'] as $operator) {
|
||||
if (str_contains($condition, $operator)) {
|
||||
[$left, $right] = array_map('trim', explode($operator, $condition, 2));
|
||||
|
||||
$leftValue = $this->parseValue($data, $left);
|
||||
$rightValue = $this->parseValue($data, $right);
|
||||
|
||||
return match ($operator) {
|
||||
'!=' => $leftValue != $rightValue,
|
||||
'==' => $leftValue == $rightValue,
|
||||
'>=' => $leftValue >= $rightValue,
|
||||
'<=' => $leftValue <= $rightValue,
|
||||
'>' => $leftValue > $rightValue,
|
||||
'<' => $leftValue < $rightValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Simple property evaluation
|
||||
$value = $this->resolveValue($data, $condition);
|
||||
return $this->isTruthy($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse value from expression (property path, string literal, or number)
|
||||
*/
|
||||
private function parseValue(array $data, string $expr): mixed
|
||||
{
|
||||
$expr = trim($expr);
|
||||
|
||||
// String literal (quoted)
|
||||
if ((str_starts_with($expr, '"') && str_ends_with($expr, '"')) ||
|
||||
(str_starts_with($expr, "'") && str_ends_with($expr, "'"))) {
|
||||
return substr($expr, 1, -1);
|
||||
}
|
||||
|
||||
// Number literal
|
||||
if (is_numeric($expr)) {
|
||||
return str_contains($expr, '.') ? (float) $expr : (int) $expr;
|
||||
}
|
||||
|
||||
// Boolean literals
|
||||
if ($expr === 'true') {
|
||||
return true;
|
||||
}
|
||||
if ($expr === 'false') {
|
||||
return false;
|
||||
}
|
||||
if ($expr === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Property path
|
||||
return $this->resolveComplexValue($data, $expr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves complex expressions including method calls and array properties
|
||||
*/
|
||||
private function resolveComplexValue(array $data, string $expr): mixed
|
||||
{
|
||||
// Handle method calls like isEmpty()
|
||||
if (str_contains($expr, '()')) {
|
||||
$methodPos = strpos($expr, '()');
|
||||
$basePath = substr($expr, 0, $methodPos);
|
||||
$methodName = substr($basePath, strrpos($basePath, '.') + 1);
|
||||
$objectPath = substr($basePath, 0, strrpos($basePath, '.'));
|
||||
|
||||
$object = $this->resolveValue($data, $objectPath);
|
||||
if (is_object($object) && method_exists($object, $methodName)) {
|
||||
return $object->$methodName();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle .length property for arrays
|
||||
if (str_ends_with($expr, '.length')) {
|
||||
$basePath = substr($expr, 0, -7);
|
||||
$value = $this->resolveValue($data, $basePath);
|
||||
|
||||
if (is_array($value)) {
|
||||
return count($value);
|
||||
}
|
||||
if (is_object($value) && method_exists($value, 'count')) {
|
||||
return $value->count();
|
||||
}
|
||||
if (is_countable($value)) {
|
||||
return count($value);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Standard property path resolution
|
||||
return $this->resolveValue($data, $expr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves nested property paths like "performance.opcacheMemoryUsage"
|
||||
*/
|
||||
private function resolveValue(array $data, string $expr): mixed
|
||||
{
|
||||
$keys = explode('.', $expr);
|
||||
$value = $data;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
} elseif (is_object($value) && isset($value->$key)) {
|
||||
$value = $value->$key;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is truthy
|
||||
*/
|
||||
private function isTruthy(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_null($value)) {
|
||||
return false;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
return trim($value) !== '';
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return $value != 0;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
return count($value) > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
return $this->evaluator->evaluateCondition($condition, $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,19 @@ namespace App\Framework\View\Processors;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Template\Expression\PlaceholderProcessor;
|
||||
use App\Framework\Template\Processing\DomProcessor;
|
||||
use App\Framework\View\DomWrapper;
|
||||
use App\Framework\View\RawHtml;
|
||||
use App\Framework\View\RenderContext;
|
||||
|
||||
final class ForProcessor implements DomProcessor
|
||||
{
|
||||
private PlaceholderProcessor $placeholderProcessor;
|
||||
|
||||
public function __construct(
|
||||
private Container $container,
|
||||
) {
|
||||
$this->placeholderProcessor = new PlaceholderProcessor();
|
||||
}
|
||||
|
||||
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
|
||||
@@ -40,8 +43,11 @@ final class ForProcessor implements DomProcessor
|
||||
$forNodesOld = $dom->document->querySelectorAll('for[var][in]');
|
||||
$forNodesNew = $dom->document->querySelectorAll('for[items][as]');
|
||||
|
||||
// Support foreach attribute on any element: <tr foreach="$models as $model">
|
||||
$foreachNodes = $dom->document->querySelectorAll('[foreach]');
|
||||
|
||||
// Merge both nodesets
|
||||
|
||||
// Merge all nodesets
|
||||
$forNodes = [];
|
||||
foreach ($forNodesOld as $node) {
|
||||
$forNodes[] = $node;
|
||||
@@ -49,11 +55,29 @@ final class ForProcessor implements DomProcessor
|
||||
foreach ($forNodesNew as $node) {
|
||||
$forNodes[] = $node;
|
||||
}
|
||||
foreach ($foreachNodes as $node) {
|
||||
$forNodes[] = $node;
|
||||
}
|
||||
|
||||
|
||||
foreach ($forNodes as $node) {
|
||||
// Detect which syntax is being used
|
||||
if ($node->hasAttribute('items') && $node->hasAttribute('as')) {
|
||||
if ($node->hasAttribute('foreach')) {
|
||||
// foreach attribute syntax: <tr foreach="$models as $model">
|
||||
$foreachExpr = $node->getAttribute('foreach');
|
||||
|
||||
// Parse "array as var" syntax (with or without $ prefix)
|
||||
if (preg_match('/^\$?(\w+)\s+as\s+\$?(\w+)$/', $foreachExpr, $matches)) {
|
||||
$in = $matches[1];
|
||||
$var = $matches[2];
|
||||
} else {
|
||||
// Invalid foreach syntax, skip this node
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove foreach attribute from element
|
||||
$node->removeAttribute('foreach');
|
||||
} elseif ($node->hasAttribute('items') && $node->hasAttribute('as')) {
|
||||
// New syntax: <for items="arrayName" as="itemVar">
|
||||
$in = $node->getAttribute('items');
|
||||
$var = $node->getAttribute('as');
|
||||
@@ -64,6 +88,10 @@ final class ForProcessor implements DomProcessor
|
||||
}
|
||||
$output = '';
|
||||
|
||||
// Check if this was a foreach attribute (already removed)
|
||||
// We detect this by checking if node is NOT a <for> element
|
||||
$isForeachAttribute = !in_array(strtolower($node->tagName), ['for']);
|
||||
|
||||
// Resolve items from context data or model
|
||||
$items = $this->resolveValue($context->data, $in);
|
||||
|
||||
@@ -88,16 +116,23 @@ final class ForProcessor implements DomProcessor
|
||||
controllerClass: $context->controllerClass
|
||||
);
|
||||
|
||||
// Get innerHTML from cloned node
|
||||
$innerHTML = $clone->innerHTML;
|
||||
// For foreach attribute: process the entire element
|
||||
// For <for> element: process only innerHTML
|
||||
if ($isForeachAttribute) {
|
||||
// Process entire element (e.g., <tr>)
|
||||
$innerHTML = $clone->outerHTML;
|
||||
} else {
|
||||
// Get innerHTML from cloned node
|
||||
$innerHTML = $clone->innerHTML;
|
||||
|
||||
// Handle case where DOM parser treats <for> as self-closing
|
||||
if (trim($innerHTML) === '') {
|
||||
$innerHTML = $this->collectSiblingContent($node, $dom);
|
||||
// Handle case where DOM parser treats <for> as self-closing
|
||||
if (trim($innerHTML) === '') {
|
||||
$innerHTML = $this->collectSiblingContent($node, $dom);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace loop variable placeholders
|
||||
$innerHTML = $this->replaceLoopVariables($innerHTML, $var, $item);
|
||||
// Replace loop variable placeholders using PlaceholderProcessor
|
||||
$innerHTML = $this->placeholderProcessor->processLoopVariable($innerHTML, $var, $item);
|
||||
|
||||
// Process placeholders in loop content
|
||||
$placeholderReplacer = $this->container->get(PlaceholderReplacer::class);
|
||||
@@ -184,51 +219,6 @@ final class ForProcessor implements DomProcessor
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces loop variable placeholders in the HTML content
|
||||
*/
|
||||
private function replaceLoopVariables(string $html, string $varName, mixed $item): string
|
||||
{
|
||||
$pattern = '/{{\\s*' . preg_quote($varName, '/') . '\\.([\\w]+)\\s*}}/';
|
||||
|
||||
return preg_replace_callback(
|
||||
$pattern,
|
||||
function ($matches) use ($item) {
|
||||
$property = $matches[1];
|
||||
|
||||
if (is_array($item) && array_key_exists($property, $item)) {
|
||||
$value = $item[$property];
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if ($value instanceof RawHtml) {
|
||||
return $value->content;
|
||||
}
|
||||
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
} elseif (is_object($item) && isset($item->$property)) {
|
||||
$value = $item->$property;
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
if ($value instanceof RawHtml) {
|
||||
return $value->content;
|
||||
}
|
||||
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
// Return placeholder unchanged if property not found
|
||||
return $matches[0];
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects content from sibling nodes when <for> is treated as self-closing
|
||||
*/
|
||||
|
||||
@@ -18,12 +18,15 @@ final readonly class ForStringProcessor implements StringProcessor
|
||||
public function process(string $content, RenderContext $context): string
|
||||
{
|
||||
error_log("🔧🔧🔧 ForStringProcessor::process() CALLED - Template: " . $context->template);
|
||||
error_log("🔧 ForStringProcessor: Processing content, looking for <for> tags");
|
||||
error_log("🔧 ForStringProcessor: Processing content, looking for <for> tags and foreach attributes");
|
||||
error_log("🔧 ForStringProcessor: Content contains '<for': " . (strpos($content, '<for') !== false ? 'YES' : 'NO'));
|
||||
error_log("🔧 ForStringProcessor: Content contains 'foreach=': " . (strpos($content, 'foreach=') !== false ? 'YES' : 'NO'));
|
||||
error_log("🔧 ForStringProcessor: Available data keys: " . implode(', ', array_keys($context->data)));
|
||||
|
||||
// Process nested <for> loops iteratively from innermost to outermost
|
||||
$result = $content;
|
||||
// FIRST: Process foreach attributes (must be done before <for> tags to handle nested cases)
|
||||
$result = $this->processForeachAttributes($content, $context);
|
||||
|
||||
// THEN: Process nested <for> loops iteratively from innermost to outermost
|
||||
$maxIterations = 10; // Prevent infinite loops
|
||||
$iteration = 0;
|
||||
|
||||
@@ -209,4 +212,146 @@ final readonly class ForStringProcessor implements StringProcessor
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process foreach attributes on elements: <tr foreach="$models as $model">
|
||||
*/
|
||||
private function processForeachAttributes(string $content, RenderContext $context): string
|
||||
{
|
||||
// Pattern to match elements with foreach attribute
|
||||
// Matches: <tagname foreach="$array as $var" ... > ... </tagname>
|
||||
// OR: <tagname foreach="array as var" ... > ... </tagname> (without $ prefix)
|
||||
$pattern = '/<([a-zA-Z][a-zA-Z0-9]*)\s+([^>]*?)foreach\s*=\s*["\']?\$?([a-zA-Z_][a-zA-Z0-9_]*)\s+as\s+\$?([a-zA-Z_][a-zA-Z0-9_]*)["\']?([^>]*?)>(.*?)<\/\1>/s';
|
||||
|
||||
$result = preg_replace_callback(
|
||||
$pattern,
|
||||
function ($matches) use ($context) {
|
||||
$tagName = $matches[1]; // e.g., "tr"
|
||||
$beforeAttrs = $matches[2]; // attributes before foreach
|
||||
$dataKey = $matches[3]; // e.g., "models"
|
||||
$varName = $matches[4]; // e.g., "model"
|
||||
$afterAttrs = $matches[5]; // attributes after foreach
|
||||
$innerHTML = $matches[6]; // content inside the element
|
||||
|
||||
error_log("🔧 ForStringProcessor: Processing foreach attribute on <$tagName>");
|
||||
error_log("🔧 ForStringProcessor: dataKey='$dataKey', varName='$varName'");
|
||||
|
||||
// Resolve the data array/collection
|
||||
$data = $this->resolveValue($context->data, $dataKey);
|
||||
|
||||
if (! is_array($data) && ! is_iterable($data)) {
|
||||
error_log("🔧 ForStringProcessor: Data for '$dataKey' is not iterable: " . gettype($data));
|
||||
return ''; // Remove the element if data is not iterable
|
||||
}
|
||||
|
||||
// Combine attributes (remove foreach attribute)
|
||||
$allAttrs = trim($beforeAttrs . ' ' . $afterAttrs);
|
||||
|
||||
$output = '';
|
||||
foreach ($data as $item) {
|
||||
// Replace loop variables in innerHTML
|
||||
$processedInnerHTML = $this->replaceForeachVariables($innerHTML, $varName, $item);
|
||||
|
||||
// Reconstruct the element
|
||||
$output .= "<{$tagName}" . ($allAttrs ? " {$allAttrs}" : '') . ">{$processedInnerHTML}</{$tagName}>";
|
||||
}
|
||||
|
||||
error_log("🔧 ForStringProcessor: foreach processing complete, generated " . count($data) . " elements");
|
||||
|
||||
return $output;
|
||||
},
|
||||
$content
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace foreach loop variables, supporting both {{ $var.property }} and {{ $var['property'] }} syntax
|
||||
*/
|
||||
private function replaceForeachVariables(string $template, string $varName, mixed $item): string
|
||||
{
|
||||
error_log("🔧 ForStringProcessor: replaceForeachVariables called for varName='$varName'");
|
||||
|
||||
// Pattern 1: {{ $var.property }} or {{ var.property }} (dot notation)
|
||||
$patternDot = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/';
|
||||
|
||||
// Pattern 2: {{ $var['property'] }} or {{ var['property'] }} (bracket notation with single quotes)
|
||||
$patternBracketSingle = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\s*\[\s*\'([^\']+)\'\s*\]\s*\}\}/';
|
||||
|
||||
// Pattern 3: {{ $var["property"] }} or {{ var["property"] }} (bracket notation with double quotes)
|
||||
$patternBracketDouble = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\s*\[\s*"([^"]+)"\s*\]\s*\}\}/';
|
||||
|
||||
// Replace all patterns
|
||||
$result = preg_replace_callback(
|
||||
$patternDot,
|
||||
function ($matches) use ($item, $varName) {
|
||||
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
|
||||
},
|
||||
$template
|
||||
);
|
||||
|
||||
$result = preg_replace_callback(
|
||||
$patternBracketSingle,
|
||||
function ($matches) use ($item, $varName) {
|
||||
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
|
||||
},
|
||||
$result
|
||||
);
|
||||
|
||||
$result = preg_replace_callback(
|
||||
$patternBracketDouble,
|
||||
function ($matches) use ($item, $varName) {
|
||||
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
|
||||
},
|
||||
$result
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a property from an item (array or object)
|
||||
*/
|
||||
private function resolveItemProperty(mixed $item, string $property, string $varName, string $originalPlaceholder): string
|
||||
{
|
||||
error_log("🔧 ForStringProcessor: Resolving property '$property' from item");
|
||||
|
||||
if (is_array($item) && array_key_exists($property, $item)) {
|
||||
$value = $item[$property];
|
||||
error_log("🔧 ForStringProcessor: Found property '$property' in array with value: " . var_export($value, true));
|
||||
|
||||
// Handle boolean values properly
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// Handle null
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
} elseif (is_object($item) && isset($item->$property)) {
|
||||
$value = $item->$property;
|
||||
error_log("🔧 ForStringProcessor: Found property '$property' in object with value: " . var_export($value, true));
|
||||
|
||||
// Handle boolean values properly
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// Handle null
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
error_log("🔧 ForStringProcessor: Property '$property' not found, returning unchanged placeholder");
|
||||
|
||||
// Return placeholder unchanged if property not found
|
||||
return $originalPlaceholder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,9 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
|
||||
// Standard Variablen und Methoden: {{ $item.getRelativeFile() }} or {{ item.getRelativeFile() }}
|
||||
// Supports both old and new syntax for backwards compatibility
|
||||
// Also supports array bracket syntax: {{ $model['key'] }} or {{ $model["key"] }}
|
||||
return preg_replace_callback(
|
||||
'/{{\\s*\\$?([\\w.]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
|
||||
'/{{\\s*\\$?([\\w.\\[\\]\'\"]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
|
||||
function ($matches) use ($context) {
|
||||
$expression = $matches[1];
|
||||
$params = isset($matches[2]) ? trim($matches[2]) : null;
|
||||
@@ -276,16 +277,34 @@ final class PlaceholderReplacer implements StringProcessor
|
||||
|
||||
private function resolveValue(array $data, string $expr): mixed
|
||||
{
|
||||
$keys = explode('.', $expr);
|
||||
// Handle array bracket syntax: $var['key'] or $var["key"]
|
||||
// Can be chained: $var['key1']['key2'] or mixed: $var.prop['key']
|
||||
$originalExpr = $expr;
|
||||
$value = $data;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
} elseif (is_object($value) && isset($value->$key)) {
|
||||
$value = $value->$key;
|
||||
} else {
|
||||
return null;
|
||||
// Split expression into parts, handling both dot notation and bracket notation
|
||||
$pattern = '/([\\w]+)|\\[([\'"])([^\\2]+?)\\2\\]/';
|
||||
preg_match_all($pattern, $expr, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
if (!empty($match[1])) {
|
||||
// Dot notation: variable.property
|
||||
$key = $match[1];
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
} elseif (is_object($value) && isset($value->$key)) {
|
||||
$value = $value->$key;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} elseif (!empty($match[3])) {
|
||||
// Bracket notation: variable['key'] or variable["key"]
|
||||
$key = $match[3];
|
||||
if (is_array($value) && array_key_exists($key, $value)) {
|
||||
$value = $value[$key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Performance\PerformanceService;
|
||||
use App\Framework\View\Dom\Transformer\AssetInjectorTransformer;
|
||||
use App\Framework\View\Dom\Transformer\CommentStripTransformer;
|
||||
use App\Framework\View\Dom\Transformer\ForTransformer;
|
||||
use App\Framework\View\Dom\Transformer\HoneypotTransformer;
|
||||
use App\Framework\View\Dom\Transformer\IfTransformer;
|
||||
use App\Framework\View\Dom\Transformer\LayoutTagTransformer;
|
||||
@@ -19,7 +20,6 @@ use App\Framework\View\Dom\Transformer\MetaManipulatorTransformer;
|
||||
use App\Framework\View\Dom\Transformer\WhitespaceCleanupTransformer;
|
||||
use App\Framework\View\Dom\Transformer\XComponentTransformer;
|
||||
use App\Framework\View\Loading\TemplateLoader;
|
||||
use App\Framework\View\Processors\ForStringProcessor;
|
||||
use App\Framework\View\Processors\PlaceholderReplacer;
|
||||
use App\Framework\View\Processors\VoidElementsSelfClosingProcessor;
|
||||
|
||||
@@ -33,11 +33,12 @@ final readonly class TemplateRendererInitializer
|
||||
#[Initializer]
|
||||
public function __invoke(): TemplateRenderer
|
||||
{
|
||||
// AST Transformers (new approach)
|
||||
// AST Transformers (new approach) - Modern template processing
|
||||
$astTransformers = [
|
||||
// Core transformers (order matters!)
|
||||
LayoutTagTransformer::class, // Process <layout> tags FIRST (before other processing)
|
||||
XComponentTransformer::class, // Process <x-*> components (LiveComponents + HtmlComponents)
|
||||
ForTransformer::class, // Process foreach loops and <for> elements (BEFORE if/placeholders)
|
||||
IfTransformer::class, // Conditional rendering (if/condition attributes)
|
||||
MetaManipulatorTransformer::class, // Set meta tags from context
|
||||
AssetInjectorTransformer::class, // Inject Vite assets (CSS/JS)
|
||||
@@ -49,11 +50,9 @@ final readonly class TemplateRendererInitializer
|
||||
// TODO: Migrate remaining DOM processors to AST transformers:
|
||||
// - ComponentProcessor (for <component> tags) - COMPLEX, keep in DOM for now
|
||||
// - TableProcessor (for table rendering) - OPTIONAL
|
||||
// - ForProcessor (DOM-based, we already have ForStringProcessor) - HANDLED
|
||||
// - FormProcessor (for form handling) - OPTIONAL
|
||||
|
||||
$strings = [
|
||||
ForStringProcessor::class, // ForStringProcessor MUST run first to process <for> loops before DOM parsing
|
||||
PlaceholderReplacer::class, // PlaceholderReplacer handles simple {{ }} replacements
|
||||
VoidElementsSelfClosingProcessor::class,
|
||||
];
|
||||
|
||||
@@ -35,19 +35,49 @@ final readonly class TelegramSignatureProvider implements SignatureProvider
|
||||
return hash_equals($secret, $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse signature from header value
|
||||
*
|
||||
* For Telegram, the signature is simply the secret token value
|
||||
*/
|
||||
public function parseSignature(string $headerValue): \App\Framework\Webhook\ValueObjects\WebhookSignature
|
||||
{
|
||||
return new \App\Framework\Webhook\ValueObjects\WebhookSignature(
|
||||
algorithm: 'token',
|
||||
signature: $headerValue,
|
||||
timestamp: null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signature (not applicable for Telegram)
|
||||
*
|
||||
* Telegram doesn't generate signatures from payload.
|
||||
* This method exists for SignatureProvider interface compliance.
|
||||
*/
|
||||
public function generate(string $payload, string $secret): string
|
||||
public function generateSignature(string $payload, string $secret): string
|
||||
{
|
||||
// For Telegram, we just return the secret token
|
||||
// It's sent as-is in the X-Telegram-Bot-Api-Secret-Token header
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected header name for Telegram webhooks
|
||||
*/
|
||||
public function getSignatureHeader(): string
|
||||
{
|
||||
return 'X-Telegram-Bot-Api-Secret-Token';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider name
|
||||
*/
|
||||
public function getProviderName(): string
|
||||
{
|
||||
return 'telegram';
|
||||
}
|
||||
|
||||
public function getAlgorithm(): string
|
||||
{
|
||||
return 'token';
|
||||
|
||||
Reference in New Issue
Block a user