diff --git a/.gitignore b/.gitignore index 6f5ed04..977a3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,5 @@ yarn-error.log /.nova /.vscode /.zed -/coverage-report* -/coverage.xml +/backend/coverage-report* /.claude diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 11ce824..0000000 --- a/Dockerfile +++ /dev/null @@ -1,127 +0,0 @@ -# Production Dockerfile with FrankenPHP -FROM dunglas/frankenphp:latest-php8.3-alpine - -# Install system dependencies -RUN apk add --no-cache \ - nodejs \ - npm \ - git \ - mysql-client - -# Install PHP extensions -RUN install-php-extensions \ - pdo_mysql \ - opcache \ - zip \ - gd \ - intl \ - bcmath \ - redis \ - pcntl - -# Install Composer -COPY --from=composer:2 /usr/bin/composer /usr/bin/composer - -# Set working directory -WORKDIR /app - -# Set fixed production environment variables -ENV APP_ENV=production \ - APP_DEBUG=false \ - DB_CONNECTION=mysql \ - DB_HOST=db \ - DB_PORT=3306 \ - SESSION_DRIVER=redis \ - CACHE_STORE=redis \ - QUEUE_CONNECTION=redis \ - LOG_CHANNEL=stack \ - LOG_LEVEL=error - -# Copy application code first -COPY . . - -# Install PHP dependencies (production only) -RUN composer install --no-dev --no-interaction --optimize-autoloader - -# Install ALL Node dependencies (including dev for building) -RUN npm ci - -# Build frontend assets -RUN npm run build - -# Remove node_modules after build to save space -RUN rm -rf node_modules - -# Laravel optimizations -RUN php artisan config:cache \ - && php artisan route:cache \ - && php artisan view:cache \ - && composer dump-autoload --optimize - -# Set permissions -RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache - -# Configure Caddy -RUN cat > /etc/caddy/Caddyfile < /start-prod.sh <<'EOF' -#!/bin/sh -set -e - -# Wait for database to be ready -echo "Waiting for database..." -for i in $(seq 1 30); do - if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then - echo "Database is ready!" - break - fi - echo "Waiting for database... ($i/30)" - sleep 2 -done - -# Run migrations -echo "Running migrations..." -php artisan migrate --force || echo "Migrations failed or already up-to-date" - -# Start Horizon in the background -php artisan horizon & - -# Start FrankenPHP -exec frankenphp run --config /etc/caddy/Caddyfile -EOF - -RUN chmod +x /start-prod.sh - -# Start with our script -CMD ["/start-prod.sh"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 124ace1..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,127 +0,0 @@ -# Development Dockerfile with FrankenPHP -FROM dunglas/frankenphp:latest-php8.3-alpine - -# Install system dependencies + development tools -RUN apk add --no-cache \ - nodejs \ - npm \ - git \ - mysql-client \ - vim \ - bash \ - nano - -# Install PHP extensions including xdebug for development -RUN install-php-extensions \ - pdo_mysql \ - opcache \ - zip \ - gd \ - intl \ - bcmath \ - redis \ - pcntl \ - xdebug - -# Install Composer -COPY --from=composer:2 /usr/bin/composer /usr/bin/composer - -# Set working directory -WORKDIR /app - -# Configure PHP for development -RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" - -# Configure Xdebug (disabled by default to reduce noise) -RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ - && echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ - && echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ - && echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - -# Configure Caddy for development (simpler, no worker mode) -RUN cat > /etc/caddy/Caddyfile < /start.sh <<'EOF' -#!/bin/sh -set -e - -# Create .env file if it doesn't exist -if [ ! -f ".env" ]; then - echo "Creating .env file from .env.example..." - cp .env.example .env -fi - -# Install dependencies if volumes are empty -if [ ! -f "vendor/autoload.php" ]; then - echo "Installing composer dependencies..." - composer install -fi - -# Always reinstall node_modules in container to get correct native binaries for Alpine/musl -echo "Installing npm dependencies..." -rm -rf node_modules 2>/dev/null || true -rm -rf /app/.npm 2>/dev/null || true -npm install --cache /tmp/.npm - -# Clear Laravel caches -php artisan config:clear || true -php artisan cache:clear || true - -# Wait for database and run migrations -echo "Waiting for database..." -sleep 5 -php artisan migrate --force || echo "Migration failed or not needed" - -# Run seeders -echo "Running seeders..." -php artisan db:seed --force || echo "Seeding skipped or already done" - -# Generate app key if not set -if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then - echo "Generating application key..." - php artisan key:generate -fi - -# Start Vite dev server in background -npm run dev & - -# Start Horizon (queue worker) in background -php artisan horizon & - -# Start FrankenPHP -exec frankenphp run --config /etc/caddy/Caddyfile -EOF - -RUN chmod +x /start.sh - -# Expose ports -EXPOSE 8000 5173 - -# Use the startup script -CMD ["/start.sh"] diff --git a/README.md b/README.md index 47f8177..577b087 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,205 @@ -# FFR (Feed to Fediverse Router) +# Fedi Feed Router -A Laravel-based application for routing RSS/Atom feeds to Fediverse platforms like Lemmy. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment. +
+FFR Logo +
+ +`ffr` is a self-hosted tool for routing content from RSS/Atom feeds to the fediverse. + +It watches feeds, matches entries based on keywords or rules, and publishes them to platforms like Lemmy, Mastodon, or anything ActivityPub-compatible. ## Features -- **Feed aggregation** - Fetch articles from multiple RSS/Atom feeds -- **Fediverse publishing** - Automatically post to Lemmy communities -- **Route configuration** - Map feeds to specific channels with keywords -- **Approval workflow** - Optional manual approval before publishing -- **Queue processing** - Background job handling with Laravel Horizon -- **Single container deployment** - Simplified hosting with FrankenPHP +- Keyword-based routing from any RSS/Atom feed +- Publish to Lemmy, Mastodon, or other fediverse services +- YAML or JSON route configs +- CLI and/or daemon mode +- Self-hosted, privacy-first, no SaaS dependencies -## Self-hosting -The production image is available at `codeberg.org/lvl0/ffr:latest`. +## Docker Deployment -### docker-compose.yml +### Building the Image + +```bash +docker build -t your-registry/lemmy-poster:latest . +docker push your-registry/lemmy-poster:latest +``` + +### Docker Compose + +Create a `docker-compose.yml` file: ```yaml services: - app: - image: codeberg.org/lvl0/ffr:latest - container_name: ffr_app - restart: always + app-web: + image: your-registry/lemmy-poster:latest + command: ["web"] ports: - "8000:8000" environment: - APP_KEY: "${APP_KEY}" - APP_URL: "${APP_URL}" - DB_DATABASE: "${DB_DATABASE}" - DB_USERNAME: "${DB_USERNAME}" - DB_PASSWORD: "${DB_PASSWORD}" - REDIS_HOST: redis - REDIS_PORT: 6379 - volumes: - - app_storage:/app/storage + - DB_DATABASE=${DB_DATABASE} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - LEMMY_INSTANCE=${LEMMY_INSTANCE} + - LEMMY_USERNAME=${LEMMY_USERNAME} + - LEMMY_PASSWORD=${LEMMY_PASSWORD} + - LEMMY_COMMUNITY=${LEMMY_COMMUNITY} depends_on: - - db - - redis - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/up"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s + - mysql + volumes: + - storage_data:/var/www/html/storage/app + restart: unless-stopped - db: - image: mariadb:11 - container_name: ffr_db - restart: always + app-queue: + image: your-registry/lemmy-poster:latest + command: ["queue"] environment: - MYSQL_DATABASE: "${DB_DATABASE}" - MYSQL_USER: "${DB_USERNAME}" - MYSQL_PASSWORD: "${DB_PASSWORD}" - MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}" + - DB_DATABASE=${DB_DATABASE} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - LEMMY_INSTANCE=${LEMMY_INSTANCE} + - LEMMY_USERNAME=${LEMMY_USERNAME} + - LEMMY_PASSWORD=${LEMMY_PASSWORD} + - LEMMY_COMMUNITY=${LEMMY_COMMUNITY} + depends_on: + - mysql volumes: - - db_data:/var/lib/mysql + - storage_data:/var/www/html/storage/app + restart: unless-stopped - redis: - image: redis:7-alpine - container_name: ffr_redis - restart: always + mysql: + image: mysql:8.0 + command: --host-cache-size=0 --innodb-use-native-aio=0 --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION --log-error-verbosity=1 + environment: + - MYSQL_DATABASE=${DB_DATABASE} + - MYSQL_USER=${DB_USERNAME} + - MYSQL_PASSWORD=${DB_PASSWORD} + - MYSQL_ROOT_PASSWORD=${DB_PASSWORD} + - TZ=UTC volumes: - - redis_data:/data + - mysql_data:/var/lib/mysql + restart: unless-stopped volumes: - db_data: - redis_data: - app_storage: + mysql_data: + storage_data: ``` ### Environment Variables -| Variable | Required | Description | -|----------|----------|-------------| -| `APP_KEY` | Yes | Encryption key. Generate with: `echo "base64:$(openssl rand -base64 32)"` | -| `APP_URL` | Yes | Your domain (e.g., `https://ffr.example.com`) | -| `DB_DATABASE` | Yes | Database name | -| `DB_USERNAME` | Yes | Database user | -| `DB_PASSWORD` | Yes | Database password | -| `DB_ROOT_PASSWORD` | Yes | MariaDB root password | +Create a `.env` file with: -## Development +```env +# Database Settings +DB_DATABASE=lemmy_poster +DB_USERNAME=lemmy_user +DB_PASSWORD=your-password -### NixOS / Nix - -```bash -git clone https://codeberg.org/lvl0/ffr.git -cd ffr -nix-shell +# Lemmy Settings +LEMMY_INSTANCE=your-lemmy-instance.com +LEMMY_USERNAME=your-lemmy-username +LEMMY_PASSWORD=your-lemmy-password +LEMMY_COMMUNITY=your-target-community ``` -The shell will display available commands and optionally start the containers for you. +### Deployment -#### Available Commands +1. Build and push the image to your registry +2. Copy the docker-compose.yml to your server +3. Create the .env file with your environment variables +4. Run: `docker compose up -d` -| Command | Description | -|---------|-------------| -| `dev-up` | Start development environment | -| `dev-down` | Stop development environment | -| `dev-restart` | Restart containers | -| `dev-logs` | Follow app logs | -| `dev-logs-db` | Follow database logs | -| `dev-shell` | Enter app container | -| `dev-artisan ` | Run artisan commands | -| `prod-build [tag]` | Build and push prod image (default: latest) | +The application will automatically: +- Wait for the database to be ready +- Run database migrations on first startup +- Start the queue worker after migrations complete +- Handle race conditions between web and queue containers -#### Services +### Initial Setup -| Service | URL | -|---------|-----| -| App | http://localhost:8000 | -| Vite | http://localhost:5173 | -| MariaDB | localhost:3307 | -| Redis | localhost:6380 | +After deployment, the article refresh will run every hour. To trigger the initial article fetch manually: -### Other Platforms +```bash +docker compose exec app-web php artisan article:refresh +``` -Contributions welcome for development setup instructions on other platforms. +The application will then automatically: +- Fetch new articles every hour +- Publish valid articles every 5 minutes +- Sync community posts every 10 minutes -## License +The web interface will be available on port 8000. -This project is open-source software licensed under the [AGPL-3.0 license](LICENSE). +### Architecture -## Support +The application uses a multi-container setup: +- **app-web**: Serves the Laravel web interface and handles HTTP requests +- **app-queue**: Processes background jobs (article fetching, Lemmy posting) +- **mysql**: Database storage for articles, logs, and application data -For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues). +Both app containers use the same Docker image but with different commands (`web` or `queue`). Environment variables are passed from your `.env` file to configure database access and Lemmy integration. + +## Development Setup + +For local development with Podman: + +### Prerequisites + +- Podman and podman-compose installed +- Git + +### Quick Start + +1. **Clone and start the development environment:** + ```bash + git clone + cd ffr + ./docker/dev/podman/start-dev.sh + ``` + +2. **Access the application:** + - **Web interface**: http://localhost:8000 + - **Vite dev server**: http://localhost:5173 + - **Database**: localhost:3307 + - **Redis**: localhost:6380 + +### Development Commands + +**Load Sail-compatible aliases:** +```bash +source docker/dev/podman/podman-sail-alias.sh +``` + +**Useful commands:** +```bash +# Run tests +ffr-test + +# Execute artisan commands +ffr-artisan migrate +ffr-artisan tinker + +# View application logs +ffr-logs + +# Open container shell +ffr-shell + +# Stop environment +podman-compose -f docker/dev/podman/docker-compose.yml down +``` + +Run tests: +```sh +podman-compose -f docker/dev/podman/docker-compose.yml exec app bash -c "cd backend && XDEBUG_MODE=coverage php artisan test --coverage-html=coverage-report" +``` + + +### Development Features + +- **Hot reload**: Vite automatically reloads frontend changes +- **Database**: Pre-configured MySQL with migrations and seeders +- **Redis**: Configured for caching, sessions, and queues +- **Laravel Horizon**: Available for queue monitoring +- **No configuration needed**: Development environment uses preset configuration diff --git a/app/Facades/LogSaver.php b/app/Facades/LogSaver.php deleted file mode 100644 index 661e618..0000000 --- a/app/Facades/LogSaver.php +++ /dev/null @@ -1,13 +0,0 @@ -id) - ->where('platform_channel_id', $channel->id) - ->orderBy('keyword') - ->get(); - - return $this->sendResponse( - $keywords->toArray(), - 'Keywords retrieved successfully.' - ); - } - - /** - * Store a new keyword for a route - */ - public function store(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse - { - try { - $validated = $request->validate([ - 'keyword' => 'required|string|max:255', - 'is_active' => 'boolean', - ]); - - $validated['feed_id'] = $feed->id; - $validated['platform_channel_id'] = $channel->id; - $validated['is_active'] = $validated['is_active'] ?? true; - - // Check if keyword already exists for this route - $existingKeyword = Keyword::where('feed_id', $feed->id) - ->where('platform_channel_id', $channel->id) - ->where('keyword', $validated['keyword']) - ->first(); - - if ($existingKeyword) { - return $this->sendError('Keyword already exists for this route.', [], 409); - } - - $keyword = Keyword::create($validated); - - return $this->sendResponse( - $keyword->toArray(), - 'Keyword created successfully!', - 201 - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to create keyword: ' . $e->getMessage(), [], 500); - } - } - - /** - * Update a keyword's status - */ - public function update(Request $request, Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse - { - try { - // Verify the keyword belongs to this route - if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { - return $this->sendNotFound('Keyword not found for this route.'); - } - - $validated = $request->validate([ - 'is_active' => 'boolean', - ]); - - $keyword->update($validated); - - return $this->sendResponse( - $keyword->fresh()->toArray(), - 'Keyword updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update keyword: ' . $e->getMessage(), [], 500); - } - } - - /** - * Remove a keyword from a route - */ - public function destroy(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse - { - try { - // Verify the keyword belongs to this route - if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { - return $this->sendNotFound('Keyword not found for this route.'); - } - - $keyword->delete(); - - return $this->sendResponse( - null, - 'Keyword deleted successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to delete keyword: ' . $e->getMessage(), [], 500); - } - } - - /** - * Toggle keyword active status - */ - public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse - { - try { - // Verify the keyword belongs to this route - if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { - return $this->sendNotFound('Keyword not found for this route.'); - } - - $newStatus = !$keyword->is_active; - $keyword->update(['is_active' => $newStatus]); - - $status = $newStatus ? 'activated' : 'deactivated'; - - return $this->sendResponse( - $keyword->fresh()->toArray(), - "Keyword {$status} successfully!" - ); - } catch (\Exception $e) { - return $this->sendError('Failed to toggle keyword status: ' . $e->getMessage(), [], 500); - } - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/OnboardingController.php b/app/Http/Controllers/Api/V1/OnboardingController.php deleted file mode 100644 index f39c83c..0000000 --- a/app/Http/Controllers/Api/V1/OnboardingController.php +++ /dev/null @@ -1,388 +0,0 @@ -exists(); - $hasFeed = Feed::where('is_active', true)->exists(); - $hasChannel = PlatformChannel::where('is_active', true)->exists(); - $hasRoute = Route::where('is_active', true)->exists(); - - // Check if onboarding was explicitly skipped or completed - $onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true'; - $onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists(); - - // User needs onboarding if: - // 1. They haven't completed or skipped onboarding AND - // 2. They don't have all required components - $hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute; - $needsOnboarding = !$onboardingCompleted && !$onboardingSkipped && !$hasAllComponents; - - // Determine current step - $currentStep = null; - if ($needsOnboarding) { - if (!$hasPlatformAccount) { - $currentStep = 'platform'; - } elseif (!$hasFeed) { - $currentStep = 'feed'; - } elseif (!$hasChannel) { - $currentStep = 'channel'; - } elseif (!$hasRoute) { - $currentStep = 'route'; - } - } - - return $this->sendResponse([ - 'needs_onboarding' => $needsOnboarding, - 'current_step' => $currentStep, - 'has_platform_account' => $hasPlatformAccount, - 'has_feed' => $hasFeed, - 'has_channel' => $hasChannel, - 'has_route' => $hasRoute, - 'onboarding_skipped' => $onboardingSkipped, - 'onboarding_completed' => $onboardingCompleted, - 'missing_components' => !$hasAllComponents && $onboardingCompleted, - ], 'Onboarding status retrieved successfully.'); - } - - /** - * Get onboarding options (languages, platform instances) - */ - public function options(): JsonResponse - { - $languages = Language::where('is_active', true) - ->orderBy('name') - ->get(['id', 'short_code', 'name', 'native_name', 'is_active']); - - $platformInstances = PlatformInstance::where('is_active', true) - ->orderBy('name') - ->get(['id', 'platform', 'url', 'name', 'description', 'is_active']); - - // Get existing feeds and channels for route creation - $feeds = Feed::where('is_active', true) - ->orderBy('name') - ->get(['id', 'name', 'url', 'type']); - - $platformChannels = PlatformChannel::where('is_active', true) - ->with(['platformInstance:id,name,url']) - ->orderBy('name') - ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']); - - // Get feed providers from config - $feedProviders = collect(config('feed.providers', [])) - ->filter(fn($provider) => $provider['is_active']) - ->values(); - - return $this->sendResponse([ - 'languages' => $languages, - 'platform_instances' => $platformInstances, - 'feeds' => $feeds, - 'platform_channels' => $platformChannels, - 'feed_providers' => $feedProviders, - ], 'Onboarding options retrieved successfully.'); - } - - /** - * Create platform account for onboarding - */ - public function createPlatform(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'instance_url' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/', - 'username' => 'required|string|max:255', - 'password' => 'required|string|min:6', - 'platform' => 'required|in:lemmy', - ], [ - 'instance_url.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)' - ]); - - if ($validator->fails()) { - throw new ValidationException($validator); - } - - $validated = $validator->validated(); - - // Normalize the instance URL - prepend https:// if needed - $instanceDomain = $validated['instance_url']; - $fullInstanceUrl = 'https://' . $instanceDomain; - - try { - // Create or get platform instance - $platformInstance = PlatformInstance::firstOrCreate([ - 'url' => $fullInstanceUrl, - 'platform' => $validated['platform'], - ], [ - 'name' => ucfirst($instanceDomain), - 'is_active' => true, - ]); - - // Authenticate with Lemmy API using the full URL - $authResponse = $this->lemmyAuthService->authenticate( - $fullInstanceUrl, - $validated['username'], - $validated['password'] - ); - - // Create platform account with the current schema - $platformAccount = PlatformAccount::create([ - 'platform' => $validated['platform'], - 'instance_url' => $fullInstanceUrl, - 'username' => $validated['username'], - 'password' => $validated['password'], - 'settings' => [ - 'display_name' => $authResponse['person_view']['person']['display_name'] ?? null, - 'description' => $authResponse['person_view']['person']['bio'] ?? null, - 'person_id' => $authResponse['person_view']['person']['id'] ?? null, - 'platform_instance_id' => $platformInstance->id, - 'api_token' => $authResponse['jwt'] ?? null, // Store JWT in settings for now - ], - 'is_active' => true, - 'status' => 'active', - ]); - - return $this->sendResponse( - new PlatformAccountResource($platformAccount), - 'Platform account created successfully.' - ); - - } catch (\App\Exceptions\PlatformAuthException $e) { - // Check if it's a rate limit error - if (str_contains($e->getMessage(), 'Rate limited by')) { - return $this->sendError($e->getMessage(), [], 429); - } - - return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422); - } catch (\Exception $e) { - - $message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; - - // If it's a network/connection issue, provide a more specific message - if (str_contains(strtolower($e->getMessage()), 'connection') || - str_contains(strtolower($e->getMessage()), 'network') || - str_contains(strtolower($e->getMessage()), 'timeout')) { - $message = 'Connection failed. Please check the instance URL and your internet connection.'; - } - - return $this->sendError($message, [], 422); - } - } - - /** - * Create feed for onboarding - */ - public function createFeed(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'name' => 'required|string|max:255', - 'provider' => 'required|in:belga,vrt', - 'language_id' => 'required|exists:languages,id', - 'description' => 'nullable|string|max:1000', - ]); - - if ($validator->fails()) { - throw new ValidationException($validator); - } - - $validated = $validator->validated(); - - // Map provider to preset URL and type as required by onboarding tests - $provider = $validated['provider']; - $url = null; - $type = 'website'; - if ($provider === 'vrt') { - $url = 'https://www.vrt.be/vrtnws/en/'; - } elseif ($provider === 'belga') { - $url = 'https://www.belganewsagency.eu/'; - } - - $feed = Feed::firstOrCreate( - ['url' => $url], - [ - 'name' => $validated['name'], - 'type' => $type, - 'provider' => $provider, - 'language_id' => $validated['language_id'], - 'description' => $validated['description'] ?? null, - 'is_active' => true, - ] - ); - - return $this->sendResponse( - new FeedResource($feed->load('language')), - 'Feed created successfully.' - ); - } - - /** - * Create channel for onboarding - * @throws ValidationException - */ - public function createChannel(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'name' => 'required|string|max:255', - 'platform_instance_id' => 'required|exists:platform_instances,id', - 'language_id' => 'required|exists:languages,id', - 'description' => 'nullable|string|max:1000', - ]); - - if ($validator->fails()) { - throw new ValidationException($validator); - } - - $validated = $validator->validated(); - - // Get the platform instance to check for active accounts - $platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']); - - // Check if there are active platform accounts for this instance - $activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url) - ->where('is_active', true) - ->get(); - - if ($activeAccounts->isEmpty()) { - return $this->sendError( - 'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.', - [], - 422 - ); - } - - $channel = PlatformChannel::create([ - 'platform_instance_id' => $validated['platform_instance_id'], - 'channel_id' => $validated['name'], // For Lemmy, this is the community name - 'name' => $validated['name'], - 'display_name' => ucfirst($validated['name']), - 'description' => $validated['description'] ?? null, - 'language_id' => $validated['language_id'], - 'is_active' => true, - ]); - - // Automatically attach the first active account to the channel - $firstAccount = $activeAccounts->first(); - $channel->platformAccounts()->attach($firstAccount->id, [ - 'is_active' => true, - 'priority' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return $this->sendResponse( - new PlatformChannelResource($channel->load(['platformInstance', 'language', 'platformAccounts'])), - 'Channel created successfully and linked to platform account.' - ); - } - - /** - * Create route for onboarding - * - * @throws ValidationException - */ - public function createRoute(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'feed_id' => 'required|exists:feeds,id', - 'platform_channel_id' => 'required|exists:platform_channels,id', - 'priority' => 'nullable|integer|min:1|max:100', - ]); - - if ($validator->fails()) { - throw new ValidationException($validator); - } - - $validated = $validator->validated(); - - $route = Route::create([ - 'feed_id' => $validated['feed_id'], - 'platform_channel_id' => $validated['platform_channel_id'], - 'priority' => $validated['priority'] ?? 50, - 'is_active' => true, - ]); - - // Trigger article discovery when the first route is created during onboarding - // This ensures articles start being fetched immediately after setup - ArticleDiscoveryJob::dispatch(); - - return $this->sendResponse( - new RouteResource($route->load(['feed', 'platformChannel'])), - 'Route created successfully.' - ); - } - - /** - * Mark onboarding as complete - */ - public function complete(): JsonResponse - { - // Track that onboarding has been completed with a timestamp - Setting::updateOrCreate( - ['key' => 'onboarding_completed'], - ['value' => now()->toIso8601String()] - ); - - return $this->sendResponse( - ['completed' => true], - 'Onboarding completed successfully.' - ); - } - - /** - * Skip onboarding - user can access the app without completing setup - */ - public function skip(): JsonResponse - { - Setting::updateOrCreate( - ['key' => 'onboarding_skipped'], - ['value' => 'true'] - ); - - return $this->sendResponse( - ['skipped' => true], - 'Onboarding skipped successfully.' - ); - } - - /** - * Reset onboarding skip status - force user back to onboarding - */ - public function resetSkip(): JsonResponse - { - Setting::where('key', 'onboarding_skipped')->delete(); - // Also reset completion status to allow re-onboarding - Setting::where('key', 'onboarding_completed')->delete(); - - return $this->sendResponse( - ['reset' => true], - 'Onboarding status reset successfully.' - ); - } -} diff --git a/app/Http/Controllers/Api/V1/PlatformChannelsController.php b/app/Http/Controllers/Api/V1/PlatformChannelsController.php deleted file mode 100644 index 9e7fcfa..0000000 --- a/app/Http/Controllers/Api/V1/PlatformChannelsController.php +++ /dev/null @@ -1,249 +0,0 @@ -orderBy('is_active', 'desc') - ->orderBy('name') - ->get(); - - return $this->sendResponse( - PlatformChannelResource::collection($channels), - 'Platform channels retrieved successfully.' - ); - } - - /** - * Store a newly created platform channel - */ - public function store(Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'platform_instance_id' => 'required|exists:platform_instances,id', - 'channel_id' => 'required|string|max:255', - 'name' => 'required|string|max:255', - 'display_name' => 'nullable|string|max:255', - 'description' => 'nullable|string', - 'is_active' => 'boolean', - ]); - - $validated['is_active'] = $validated['is_active'] ?? true; - - // Get the platform instance to check for active accounts - $platformInstance = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']); - - // Check if there are active platform accounts for this instance - $activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url) - ->where('is_active', true) - ->get(); - - if ($activeAccounts->isEmpty()) { - return $this->sendError( - 'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.', - [], - 422 - ); - } - - $channel = PlatformChannel::create($validated); - - // Automatically attach the first active account to the channel - $firstAccount = $activeAccounts->first(); - $channel->platformAccounts()->attach($firstAccount->id, [ - 'is_active' => true, - 'priority' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return $this->sendResponse( - new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts'])), - 'Platform channel created successfully and linked to platform account!', - 201 - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to create platform channel: ' . $e->getMessage(), [], 500); - } - } - - /** - * Display the specified platform channel - */ - public function show(PlatformChannel $platformChannel): JsonResponse - { - return $this->sendResponse( - new PlatformChannelResource($platformChannel->load('platformInstance')), - 'Platform channel retrieved successfully.' - ); - } - - /** - * Update the specified platform channel - */ - public function update(Request $request, PlatformChannel $platformChannel): JsonResponse - { - try { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'display_name' => 'nullable|string|max:255', - 'description' => 'nullable|string', - 'is_active' => 'boolean', - ]); - - $platformChannel->update($validated); - - return $this->sendResponse( - new PlatformChannelResource($platformChannel->fresh(['platformInstance'])), - 'Platform channel updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update platform channel: ' . $e->getMessage(), [], 500); - } - } - - /** - * Remove the specified platform channel - */ - public function destroy(PlatformChannel $platformChannel): JsonResponse - { - try { - $platformChannel->delete(); - - return $this->sendResponse( - null, - 'Platform channel deleted successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to delete platform channel: ' . $e->getMessage(), [], 500); - } - } - - /** - * Toggle platform channel active status - */ - public function toggle(PlatformChannel $channel): JsonResponse - { - try { - $newStatus = !$channel->is_active; - $channel->update(['is_active' => $newStatus]); - - $status = $newStatus ? 'activated' : 'deactivated'; - - return $this->sendResponse( - new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])), - "Platform channel {$status} successfully!" - ); - } catch (\Exception $e) { - return $this->sendError('Failed to toggle platform channel status: ' . $e->getMessage(), [], 500); - } - } - - /** - * Attach a platform account to a channel - */ - public function attachAccount(PlatformChannel $channel, Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'platform_account_id' => 'required|exists:platform_accounts,id', - 'is_active' => 'boolean', - 'priority' => 'nullable|integer|min:1|max:100', - ]); - - $platformAccount = PlatformAccount::findOrFail($validated['platform_account_id']); - - // Check if account is already attached - if ($channel->platformAccounts()->where('platform_account_id', $platformAccount->id)->exists()) { - return $this->sendError('Platform account is already attached to this channel.', [], 422); - } - - $channel->platformAccounts()->attach($platformAccount->id, [ - 'is_active' => $validated['is_active'] ?? true, - 'priority' => $validated['priority'] ?? 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return $this->sendResponse( - new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])), - 'Platform account attached to channel successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to attach platform account: ' . $e->getMessage(), [], 500); - } - } - - /** - * Detach a platform account from a channel - */ - public function detachAccount(PlatformChannel $channel, PlatformAccount $account): JsonResponse - { - try { - if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) { - return $this->sendError('Platform account is not attached to this channel.', [], 422); - } - - $channel->platformAccounts()->detach($account->id); - - return $this->sendResponse( - new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])), - 'Platform account detached from channel successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to detach platform account: ' . $e->getMessage(), [], 500); - } - } - - /** - * Update platform account-channel relationship settings - */ - public function updateAccountRelation(PlatformChannel $channel, PlatformAccount $account, Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'is_active' => 'boolean', - 'priority' => 'nullable|integer|min:1|max:100', - ]); - - if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) { - return $this->sendError('Platform account is not attached to this channel.', [], 422); - } - - $channel->platformAccounts()->updateExistingPivot($account->id, [ - 'is_active' => $validated['is_active'] ?? true, - 'priority' => $validated['priority'] ?? 1, - 'updated_at' => now(), - ]); - - return $this->sendResponse( - new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])), - 'Platform account-channel relationship updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500); - } - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php deleted file mode 100644 index 613bcd9..0000000 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ /dev/null @@ -1,47 +0,0 @@ -authenticate(); - - $request->session()->regenerate(); - - return redirect()->intended(route('dashboard', absolute: false)); - } - - /** - * Destroy an authenticated session. - */ - public function destroy(Request $request): RedirectResponse - { - Auth::guard('web')->logout(); - - $request->session()->invalidate(); - - $request->session()->regenerateToken(); - - return redirect('/'); - } -} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php deleted file mode 100644 index 712394a..0000000 --- a/app/Http/Controllers/Auth/ConfirmablePasswordController.php +++ /dev/null @@ -1,40 +0,0 @@ -validate([ - 'email' => $request->user()->email, - 'password' => $request->password, - ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); - } - - $request->session()->put('auth.password_confirmed_at', time()); - - return redirect()->intended(route('dashboard', absolute: false)); - } -} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php deleted file mode 100644 index f64fa9b..0000000 --- a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php +++ /dev/null @@ -1,24 +0,0 @@ -user()->hasVerifiedEmail()) { - return redirect()->intended(route('dashboard', absolute: false)); - } - - $request->user()->sendEmailVerificationNotification(); - - return back()->with('status', 'verification-link-sent'); - } -} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php deleted file mode 100644 index ee3cb6f..0000000 --- a/app/Http/Controllers/Auth/EmailVerificationPromptController.php +++ /dev/null @@ -1,21 +0,0 @@ -user()->hasVerifiedEmail() - ? redirect()->intended(route('dashboard', absolute: false)) - : view('auth.verify-email'); - } -} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php deleted file mode 100644 index e8368bd..0000000 --- a/app/Http/Controllers/Auth/NewPasswordController.php +++ /dev/null @@ -1,62 +0,0 @@ - $request]); - } - - /** - * Handle an incoming new password request. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function store(Request $request): RedirectResponse - { - $request->validate([ - 'token' => ['required'], - 'email' => ['required', 'email'], - 'password' => ['required', 'confirmed', Rules\Password::defaults()], - ]); - - // Here we will attempt to reset the user's password. If it is successful we - // will update the password on an actual user model and persist it to the - // database. Otherwise we will parse the error and return the response. - $status = Password::reset( - $request->only('email', 'password', 'password_confirmation', 'token'), - function (User $user) use ($request) { - $user->forceFill([ - 'password' => Hash::make($request->password), - 'remember_token' => Str::random(60), - ])->save(); - - event(new PasswordReset($user)); - } - ); - - // If the password was successfully reset, we will redirect the user back to - // the application's home authenticated view. If there is an error we can - // redirect them back to where they came from with their error message. - return $status == Password::PASSWORD_RESET - ? redirect()->route('login')->with('status', __($status)) - : back()->withInput($request->only('email')) - ->withErrors(['email' => __($status)]); - } -} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php deleted file mode 100644 index 6916409..0000000 --- a/app/Http/Controllers/Auth/PasswordController.php +++ /dev/null @@ -1,29 +0,0 @@ -validateWithBag('updatePassword', [ - 'current_password' => ['required', 'current_password'], - 'password' => ['required', Password::defaults(), 'confirmed'], - ]); - - $request->user()->update([ - 'password' => Hash::make($validated['password']), - ]); - - return back()->with('status', 'password-updated'); - } -} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php deleted file mode 100644 index bf1ebfa..0000000 --- a/app/Http/Controllers/Auth/PasswordResetLinkController.php +++ /dev/null @@ -1,44 +0,0 @@ -validate([ - 'email' => ['required', 'email'], - ]); - - // We will send the password reset link to this user. Once we have attempted - // to send the link, we will examine the response then see the message we - // need to show to the user. Finally, we'll send out a proper response. - $status = Password::sendResetLink( - $request->only('email') - ); - - return $status == Password::RESET_LINK_SENT - ? back()->with('status', __($status)) - : back()->withInput($request->only('email')) - ->withErrors(['email' => __($status)]); - } -} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php deleted file mode 100644 index 0739e2e..0000000 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ /dev/null @@ -1,50 +0,0 @@ -validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], - 'password' => ['required', 'confirmed', Rules\Password::defaults()], - ]); - - $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), - ]); - - event(new Registered($user)); - - Auth::login($user); - - return redirect(route('dashboard', absolute: false)); - } -} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php deleted file mode 100644 index 784765e..0000000 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ /dev/null @@ -1,27 +0,0 @@ -user()->hasVerifiedEmail()) { - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); - } - - if ($request->user()->markEmailAsVerified()) { - event(new Verified($request->user())); - } - - return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); - } -} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php deleted file mode 100644 index a48eb8d..0000000 --- a/app/Http/Controllers/ProfileController.php +++ /dev/null @@ -1,60 +0,0 @@ - $request->user(), - ]); - } - - /** - * Update the user's profile information. - */ - public function update(ProfileUpdateRequest $request): RedirectResponse - { - $request->user()->fill($request->validated()); - - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; - } - - $request->user()->save(); - - return Redirect::route('profile.edit')->with('status', 'profile-updated'); - } - - /** - * Delete the user's account. - */ - public function destroy(Request $request): RedirectResponse - { - $request->validateWithBag('userDeletion', [ - 'password' => ['required', 'current_password'], - ]); - - $user = $request->user(); - - Auth::logout(); - - $user->delete(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return Redirect::to('/'); - } -} diff --git a/app/Http/Middleware/EnsureOnboardingComplete.php b/app/Http/Middleware/EnsureOnboardingComplete.php deleted file mode 100644 index b57d117..0000000 --- a/app/Http/Middleware/EnsureOnboardingComplete.php +++ /dev/null @@ -1,29 +0,0 @@ -onboardingService->needsOnboarding()) { - return redirect()->route('onboarding'); - } - - return $next($request); - } -} \ No newline at end of file diff --git a/app/Http/Middleware/RedirectIfOnboardingComplete.php b/app/Http/Middleware/RedirectIfOnboardingComplete.php deleted file mode 100644 index 190bd77..0000000 --- a/app/Http/Middleware/RedirectIfOnboardingComplete.php +++ /dev/null @@ -1,29 +0,0 @@ -onboardingService->needsOnboarding()) { - return redirect()->route('dashboard'); - } - - return $next($request); - } -} \ No newline at end of file diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php deleted file mode 100644 index 2574642..0000000 --- a/app/Http/Requests/Auth/LoginRequest.php +++ /dev/null @@ -1,85 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'email' => ['required', 'string', 'email'], - 'password' => ['required', 'string'], - ]; - } - - /** - * Attempt to authenticate the request's credentials. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function authenticate(): void - { - $this->ensureIsNotRateLimited(); - - if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { - RateLimiter::hit($this->throttleKey()); - - throw ValidationException::withMessages([ - 'email' => trans('auth.failed'), - ]); - } - - RateLimiter::clear($this->throttleKey()); - } - - /** - * Ensure the login request is not rate limited. - * - * @throws \Illuminate\Validation\ValidationException - */ - public function ensureIsNotRateLimited(): void - { - if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { - return; - } - - event(new Lockout($this)); - - $seconds = RateLimiter::availableIn($this->throttleKey()); - - throw ValidationException::withMessages([ - 'email' => trans('auth.throttle', [ - 'seconds' => $seconds, - 'minutes' => ceil($seconds / 60), - ]), - ]); - } - - /** - * Get the rate limiting throttle key for the request. - */ - public function throttleKey(): string - { - return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); - } -} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php deleted file mode 100644 index 3622a8f..0000000 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ /dev/null @@ -1,30 +0,0 @@ -|string> - */ - public function rules(): array - { - return [ - 'name' => ['required', 'string', 'max:255'], - 'email' => [ - 'required', - 'string', - 'lowercase', - 'email', - 'max:255', - Rule::unique(User::class)->ignore($this->user()->id), - ], - ]; - } -} diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php deleted file mode 100644 index 4e5fc0d..0000000 --- a/app/Jobs/PublishNextArticleJob.php +++ /dev/null @@ -1,69 +0,0 @@ -onQueue('publishing'); - } - - /** - * Execute the job. - * @throws PublishException - */ - public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void - { - // Get the oldest approved article that hasn't been published yet - $article = Article::where('approval_status', 'approved') - ->whereDoesntHave('articlePublication') - ->oldest('created_at') - ->first(); - - if (! $article) { - return; - } - - logger()->info('Publishing next article from scheduled job', [ - 'article_id' => $article->id, - 'title' => $article->title, - 'url' => $article->url, - 'created_at' => $article->created_at - ]); - - // Fetch article data - $extractedData = $articleFetcher->fetchArticleData($article); - - try { - $publishingService->publishToRoutedChannels($article, $extractedData); - - logger()->info('Successfully published article', [ - 'article_id' => $article->id, - 'title' => $article->title - ]); - } catch (PublishException $e) { - logger()->error('Failed to publish article', [ - 'article_id' => $article->id, - 'error' => $e->getMessage() - ]); - - throw $e; - } - } -} \ No newline at end of file diff --git a/app/Listeners/LogExceptionToDatabase.php b/app/Listeners/LogExceptionToDatabase.php deleted file mode 100644 index 3ccfa07..0000000 --- a/app/Listeners/LogExceptionToDatabase.php +++ /dev/null @@ -1,38 +0,0 @@ -message) > 255 - ? substr($event->message, 0, 252) . '...' - : $event->message; - - try { - $log = Log::create([ - 'level' => $event->level, - 'message' => $message, - 'context' => [ - 'exception_class' => get_class($event->exception), - 'file' => $event->exception->getFile(), - 'line' => $event->exception->getLine(), - 'trace' => $event->exception->getTraceAsString(), - ...$event->context - ] - ]); - - ExceptionLogged::dispatch($log); - } catch (\Exception $e) { - // Prevent infinite recursion by not logging this exception - // Optionally log to file or other non-database destination - error_log("Failed to log exception to database: " . $e->getMessage()); - } - } -} diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php deleted file mode 100644 index 4cce6b3..0000000 --- a/app/Livewire/Articles.php +++ /dev/null @@ -1,61 +0,0 @@ -approve(); - - $this->dispatch('article-updated'); - } - - public function reject(int $articleId): void - { - $article = Article::findOrFail($articleId); - $article->reject(); - - $this->dispatch('article-updated'); - } - - public function refresh(): void - { - $this->isRefreshing = true; - - ArticleDiscoveryJob::dispatch(); - - // Reset after 10 seconds - $this->dispatch('refresh-complete')->self(); - } - - public function refreshComplete(): void - { - $this->isRefreshing = false; - } - - public function render() - { - $articles = Article::with(['feed', 'articlePublication']) - ->orderBy('created_at', 'desc') - ->paginate(15); - - $approvalsEnabled = Setting::isPublishingApprovalsEnabled(); - - return view('livewire.articles', [ - 'articles' => $articles, - 'approvalsEnabled' => $approvalsEnabled, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Channels.php b/app/Livewire/Channels.php deleted file mode 100644 index c9a41ff..0000000 --- a/app/Livewire/Channels.php +++ /dev/null @@ -1,73 +0,0 @@ -is_active = !$channel->is_active; - $channel->save(); - } - - public function openAccountModal(int $channelId): void - { - $this->managingChannelId = $channelId; - } - - public function closeAccountModal(): void - { - $this->managingChannelId = null; - } - - public function attachAccount(int $accountId): void - { - if (!$this->managingChannelId) { - return; - } - - $channel = PlatformChannel::findOrFail($this->managingChannelId); - - if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) { - $channel->platformAccounts()->attach($accountId, [ - 'is_active' => true, - 'priority' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - } - - public function detachAccount(int $channelId, int $accountId): void - { - $channel = PlatformChannel::findOrFail($channelId); - $channel->platformAccounts()->detach($accountId); - } - - public function render() - { - $channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get(); - $allAccounts = PlatformAccount::where('is_active', true)->get(); - - $managingChannel = $this->managingChannelId - ? PlatformChannel::with('platformAccounts')->find($this->managingChannelId) - : null; - - $availableAccounts = $managingChannel - ? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id)) - : collect(); - - return view('livewire.channels', [ - 'channels' => $channels, - 'managingChannel' => $managingChannel, - 'availableAccounts' => $availableAccounts, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php deleted file mode 100644 index 66e30b1..0000000 --- a/app/Livewire/Dashboard.php +++ /dev/null @@ -1,36 +0,0 @@ -period = $period; - } - - public function render() - { - $service = app(DashboardStatsService::class); - - $articleStats = $service->getStats($this->period); - $systemStats = $service->getSystemStats(); - $availablePeriods = $service->getAvailablePeriods(); - - return view('livewire.dashboard', [ - 'articleStats' => $articleStats, - 'systemStats' => $systemStats, - 'availablePeriods' => $availablePeriods, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Feeds.php b/app/Livewire/Feeds.php deleted file mode 100644 index e2f9ae7..0000000 --- a/app/Livewire/Feeds.php +++ /dev/null @@ -1,25 +0,0 @@ -is_active = !$feed->is_active; - $feed->save(); - } - - public function render() - { - $feeds = Feed::orderBy('name')->get(); - - return view('livewire.feeds', [ - 'feeds' => $feeds, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php deleted file mode 100644 index defd807..0000000 --- a/app/Livewire/Onboarding.php +++ /dev/null @@ -1,349 +0,0 @@ -lemmyAuthService = $lemmyAuthService; - } - - public function mount(): void - { - // Check for existing platform account - $account = PlatformAccount::where('is_active', true)->first(); - if ($account) { - $this->existingAccount = [ - 'id' => $account->id, - 'username' => $account->username, - 'instance_url' => $account->instance_url, - ]; - } - - // Pre-fill feed form if exists - $feed = Feed::where('is_active', true)->first(); - if ($feed) { - $this->feedName = $feed->name; - $this->feedProvider = $feed->provider ?? 'vrt'; - $this->feedLanguageId = $feed->language_id; - $this->feedDescription = $feed->description ?? ''; - } - - // Pre-fill channel form if exists - $channel = PlatformChannel::where('is_active', true)->first(); - if ($channel) { - $this->channelName = $channel->name; - $this->platformInstanceId = $channel->platform_instance_id; - $this->channelLanguageId = $channel->language_id; - $this->channelDescription = $channel->description ?? ''; - } - - // Pre-fill route form if exists - $route = Route::where('is_active', true)->first(); - if ($route) { - $this->routeFeedId = $route->feed_id; - $this->routeChannelId = $route->platform_channel_id; - $this->routePriority = $route->priority; - } - } - - public function goToStep(int $step): void - { - $this->step = $step; - $this->errors = []; - } - - public function nextStep(): void - { - $this->step++; - $this->errors = []; - } - - public function previousStep(): void - { - if ($this->step > 1) { - $this->step--; - $this->errors = []; - } - } - - public function continueWithExistingAccount(): void - { - $this->nextStep(); - } - - public function deleteAccount(): void - { - if ($this->existingAccount) { - PlatformAccount::destroy($this->existingAccount['id']); - $this->existingAccount = null; - } - } - - public function createPlatformAccount(): void - { - $this->errors = []; - $this->isLoading = true; - - $this->validate([ - 'instanceUrl' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/', - 'username' => 'required|string|max:255', - 'password' => 'required|string|min:6', - ], [ - 'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)', - ]); - - $fullInstanceUrl = 'https://' . $this->instanceUrl; - - try { - // Create or get platform instance - $platformInstance = PlatformInstance::firstOrCreate([ - 'url' => $fullInstanceUrl, - 'platform' => 'lemmy', - ], [ - 'name' => ucfirst($this->instanceUrl), - 'is_active' => true, - ]); - - // Authenticate with Lemmy API - $authResponse = $this->lemmyAuthService->authenticate( - $fullInstanceUrl, - $this->username, - $this->password - ); - - // Create platform account - $platformAccount = PlatformAccount::create([ - 'platform' => 'lemmy', - 'instance_url' => $fullInstanceUrl, - 'username' => $this->username, - 'password' => Crypt::encryptString($this->password), - 'settings' => [ - 'display_name' => $authResponse['person_view']['person']['display_name'] ?? null, - 'description' => $authResponse['person_view']['person']['bio'] ?? null, - 'person_id' => $authResponse['person_view']['person']['id'] ?? null, - 'platform_instance_id' => $platformInstance->id, - 'api_token' => $authResponse['jwt'] ?? null, - ], - 'is_active' => true, - 'status' => 'active', - ]); - - $this->existingAccount = [ - 'id' => $platformAccount->id, - 'username' => $platformAccount->username, - 'instance_url' => $platformAccount->instance_url, - ]; - - $this->nextStep(); - } catch (\App\Exceptions\PlatformAuthException $e) { - if (str_contains($e->getMessage(), 'Rate limited by')) { - $this->errors['general'] = $e->getMessage(); - } else { - $this->errors['general'] = 'Invalid username or password. Please check your credentials and try again.'; - } - } catch (\Exception $e) { - $this->errors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; - } finally { - $this->isLoading = false; - } - } - - public function createFeed(): void - { - $this->errors = []; - $this->isLoading = true; - - $this->validate([ - 'feedName' => 'required|string|max:255', - 'feedProvider' => 'required|in:belga,vrt', - 'feedLanguageId' => 'required|exists:languages,id', - 'feedDescription' => 'nullable|string|max:1000', - ]); - - try { - // Map provider to URL - $url = $this->feedProvider === 'vrt' - ? 'https://www.vrt.be/vrtnws/en/' - : 'https://www.belganewsagency.eu/'; - - Feed::firstOrCreate( - ['url' => $url], - [ - 'name' => $this->feedName, - 'type' => 'website', - 'provider' => $this->feedProvider, - 'language_id' => $this->feedLanguageId, - 'description' => $this->feedDescription ?: null, - 'is_active' => true, - ] - ); - - $this->nextStep(); - } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create feed. Please try again.'; - } finally { - $this->isLoading = false; - } - } - - public function createChannel(): void - { - $this->errors = []; - $this->isLoading = true; - - $this->validate([ - 'channelName' => 'required|string|max:255', - 'platformInstanceId' => 'required|exists:platform_instances,id', - 'channelLanguageId' => 'required|exists:languages,id', - 'channelDescription' => 'nullable|string|max:1000', - ]); - - try { - $platformInstance = PlatformInstance::findOrFail($this->platformInstanceId); - - // Check for active platform accounts - $activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url) - ->where('is_active', true) - ->get(); - - if ($activeAccounts->isEmpty()) { - $this->errors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.'; - $this->isLoading = false; - return; - } - - $channel = PlatformChannel::create([ - 'platform_instance_id' => $this->platformInstanceId, - 'channel_id' => $this->channelName, - 'name' => $this->channelName, - 'display_name' => ucfirst($this->channelName), - 'description' => $this->channelDescription ?: null, - 'language_id' => $this->channelLanguageId, - 'is_active' => true, - ]); - - // Attach first active account - $channel->platformAccounts()->attach($activeAccounts->first()->id, [ - 'is_active' => true, - 'priority' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - $this->nextStep(); - } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create channel. Please try again.'; - } finally { - $this->isLoading = false; - } - } - - public function createRoute(): void - { - $this->errors = []; - $this->isLoading = true; - - $this->validate([ - 'routeFeedId' => 'required|exists:feeds,id', - 'routeChannelId' => 'required|exists:platform_channels,id', - 'routePriority' => 'nullable|integer|min:1|max:100', - ]); - - try { - Route::create([ - 'feed_id' => $this->routeFeedId, - 'platform_channel_id' => $this->routeChannelId, - 'priority' => $this->routePriority, - 'is_active' => true, - ]); - - // Trigger article discovery - ArticleDiscoveryJob::dispatch(); - - $this->nextStep(); - } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create route. Please try again.'; - } finally { - $this->isLoading = false; - } - } - - public function completeOnboarding(): void - { - Setting::updateOrCreate( - ['key' => 'onboarding_completed'], - ['value' => now()->toIso8601String()] - ); - - app(OnboardingService::class)->clearCache(); - - $this->redirect(route('dashboard')); - } - - public function render() - { - $languages = Language::where('is_active', true)->orderBy('name')->get(); - $platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get(); - $feeds = Feed::where('is_active', true)->orderBy('name')->get(); - $channels = PlatformChannel::where('is_active', true)->orderBy('name')->get(); - - $feedProviders = collect(config('feed.providers', [])) - ->filter(fn($provider) => $provider['is_active'] ?? false) - ->values(); - - return view('livewire.onboarding', [ - 'languages' => $languages, - 'platformInstances' => $platformInstances, - 'feeds' => $feeds, - 'channels' => $channels, - 'feedProviders' => $feedProviders, - ])->layout('layouts.guest'); - } -} diff --git a/app/Livewire/Routes.php b/app/Livewire/Routes.php deleted file mode 100644 index 29e3764..0000000 --- a/app/Livewire/Routes.php +++ /dev/null @@ -1,200 +0,0 @@ -showCreateModal = true; - $this->newFeedId = null; - $this->newChannelId = null; - $this->newPriority = 50; - } - - public function closeCreateModal(): void - { - $this->showCreateModal = false; - } - - public function createRoute(): void - { - $this->validate([ - 'newFeedId' => 'required|exists:feeds,id', - 'newChannelId' => 'required|exists:platform_channels,id', - 'newPriority' => 'required|integer|min:0', - ]); - - $exists = Route::where('feed_id', $this->newFeedId) - ->where('platform_channel_id', $this->newChannelId) - ->exists(); - - if ($exists) { - $this->addError('newFeedId', 'This route already exists.'); - return; - } - - Route::create([ - 'feed_id' => $this->newFeedId, - 'platform_channel_id' => $this->newChannelId, - 'priority' => $this->newPriority, - 'is_active' => true, - ]); - - $this->closeCreateModal(); - } - - public function openEditModal(int $feedId, int $channelId): void - { - $route = Route::where('feed_id', $feedId) - ->where('platform_channel_id', $channelId) - ->firstOrFail(); - - $this->editingFeedId = $feedId; - $this->editingChannelId = $channelId; - $this->editPriority = $route->priority; - $this->newKeyword = ''; - $this->showKeywordInput = false; - } - - public function closeEditModal(): void - { - $this->editingFeedId = null; - $this->editingChannelId = null; - } - - public function updateRoute(): void - { - if (!$this->editingFeedId || !$this->editingChannelId) { - return; - } - - $this->validate([ - 'editPriority' => 'required|integer|min:0', - ]); - - Route::where('feed_id', $this->editingFeedId) - ->where('platform_channel_id', $this->editingChannelId) - ->update(['priority' => $this->editPriority]); - - $this->closeEditModal(); - } - - public function toggle(int $feedId, int $channelId): void - { - $route = Route::where('feed_id', $feedId) - ->where('platform_channel_id', $channelId) - ->firstOrFail(); - - $route->is_active = !$route->is_active; - $route->save(); - } - - public function delete(int $feedId, int $channelId): void - { - // Delete associated keywords first - Keyword::where('feed_id', $feedId) - ->where('platform_channel_id', $channelId) - ->delete(); - - Route::where('feed_id', $feedId) - ->where('platform_channel_id', $channelId) - ->delete(); - } - - public function addKeyword(): void - { - if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) { - return; - } - - Keyword::create([ - 'feed_id' => $this->editingFeedId, - 'platform_channel_id' => $this->editingChannelId, - 'keyword' => trim($this->newKeyword), - 'is_active' => true, - ]); - - $this->newKeyword = ''; - $this->showKeywordInput = false; - } - - public function toggleKeyword(int $keywordId): void - { - $keyword = Keyword::findOrFail($keywordId); - $keyword->is_active = !$keyword->is_active; - $keyword->save(); - } - - public function deleteKeyword(int $keywordId): void - { - Keyword::destroy($keywordId); - } - - public function render() - { - $routes = Route::with(['feed', 'platformChannel']) - ->orderBy('priority', 'desc') - ->get(); - - // Batch load keywords for all routes to avoid N+1 queries - $routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id); - $allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id')) - ->whereIn('platform_channel_id', $routes->pluck('platform_channel_id')) - ->get() - ->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id); - - $routes = $routes->map(function ($route) use ($allKeywords) { - $key = $route->feed_id . '-' . $route->platform_channel_id; - $route->keywords = $allKeywords->get($key, collect()); - return $route; - }); - - $feeds = Feed::where('is_active', true)->orderBy('name')->get(); - $channels = PlatformChannel::where('is_active', true)->orderBy('name')->get(); - - $editingRoute = null; - $editingKeywords = collect(); - - if ($this->editingFeedId && $this->editingChannelId) { - $editingRoute = Route::with(['feed', 'platformChannel']) - ->where('feed_id', $this->editingFeedId) - ->where('platform_channel_id', $this->editingChannelId) - ->first(); - - $editingKeywords = Keyword::where('feed_id', $this->editingFeedId) - ->where('platform_channel_id', $this->editingChannelId) - ->get(); - } - - return view('livewire.routes', [ - 'routes' => $routes, - 'feeds' => $feeds, - 'channels' => $channels, - 'editingRoute' => $editingRoute, - 'editingKeywords' => $editingKeywords, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php deleted file mode 100644 index f63de38..0000000 --- a/app/Livewire/Settings.php +++ /dev/null @@ -1,55 +0,0 @@ -articleProcessingEnabled = Setting::isArticleProcessingEnabled(); - $this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled(); - } - - public function toggleArticleProcessing(): void - { - $this->articleProcessingEnabled = !$this->articleProcessingEnabled; - Setting::setArticleProcessingEnabled($this->articleProcessingEnabled); - $this->showSuccess(); - } - - public function togglePublishingApprovals(): void - { - $this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled; - Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled); - $this->showSuccess(); - } - - protected function showSuccess(): void - { - $this->successMessage = 'Settings updated successfully!'; - $this->errorMessage = null; - - // Clear success message after 3 seconds - $this->dispatch('clear-message'); - } - - public function clearMessages(): void - { - $this->successMessage = null; - $this->errorMessage = null; - } - - public function render() - { - return view('livewire.settings')->layout('layouts.app'); - } -} diff --git a/app/Modules/Lemmy/LemmyRequest.php b/app/Modules/Lemmy/LemmyRequest.php deleted file mode 100644 index 4df170c..0000000 --- a/app/Modules/Lemmy/LemmyRequest.php +++ /dev/null @@ -1,88 +0,0 @@ -scheme = strtolower($m[1]); - } - // Handle both full URLs and just domain names - $this->instance = $this->normalizeInstance($instance); - $this->token = $token; - } - - /** - * Normalize instance URL to just the domain name - */ - private function normalizeInstance(string $instance): string - { - // Remove protocol if present - $instance = preg_replace('/^https?:\/\//i', '', $instance); - - // Remove trailing slash if present - $instance = rtrim($instance, '/'); - - return $instance; - } - - /** - * Explicitly set the scheme (http or https) for subsequent requests. - */ - public function withScheme(string $scheme): self - { - $scheme = strtolower($scheme); - if (in_array($scheme, ['http', 'https'], true)) { - $this->scheme = $scheme; - } - return $this; - } - - /** - * @param array $params - */ - public function get(string $endpoint, array $params = []): Response - { - $url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->instance, $endpoint); - - $request = Http::timeout(30); - - if ($this->token) { - $request = $request->withToken($this->token); - } - - return $request->get($url, $params); - } - - /** - * @param array $data - */ - public function post(string $endpoint, array $data = []): Response - { - $url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->instance, $endpoint); - - $request = Http::timeout(30); - - if ($this->token) { - $request = $request->withToken($this->token); - } - - return $request->post($url, $data); - } - - public function withToken(string $token): self - { - $this->token = $token; - return $this; - } -} diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php deleted file mode 100644 index 271b8d7..0000000 --- a/app/Services/Article/ValidationService.php +++ /dev/null @@ -1,75 +0,0 @@ -id); - - $articleData = $this->articleFetcher->fetchArticleData($article); - - // Update article with fetched metadata (title, description) - $updateData = []; - - if (!empty($articleData)) { - $updateData['title'] = $articleData['title'] ?? $article->title; - $updateData['description'] = $articleData['description'] ?? $article->description; - $updateData['content'] = $articleData['full_article'] ?? null; - } - - if (!isset($articleData['full_article']) || empty($articleData['full_article'])) { - logger()->warning('Article data missing full_article content', [ - 'article_id' => $article->id, - 'url' => $article->url - ]); - - $updateData['approval_status'] = 'rejected'; - $article->update($updateData); - - return $article->refresh(); - } - - // Validate using extracted content (not stored) - $validationResult = $this->validateByKeywords($articleData['full_article']); - $updateData['approval_status'] = $validationResult ? 'approved' : 'pending'; - - $article->update($updateData); - - return $article->refresh(); - } - - private function validateByKeywords(string $full_article): bool - { - // Belgian news content keywords - broader set for Belgian news relevance - $keywords = [ - // Political parties and leaders - 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', - 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', - - // Belgian locations and institutions - 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', - 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', - 'parliament', 'government', 'minister', 'policy', 'law', 'legislation', - - // Common Belgian news topics - 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', - 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' - ]; - - foreach ($keywords as $keyword) { - if (stripos($full_article, $keyword) !== false) { - return true; - } - } - - return false; - } -} diff --git a/app/Services/Auth/LemmyAuthService.php b/app/Services/Auth/LemmyAuthService.php deleted file mode 100644 index dfd6c11..0000000 --- a/app/Services/Auth/LemmyAuthService.php +++ /dev/null @@ -1,72 +0,0 @@ -username || ! $account->password || ! $account->instance_url) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); - } - - $api = new LemmyApiService($account->instance_url); - $token = $api->login($account->username, $account->password); - - if (!$token) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); - } - - return $token; - } - - /** - * Authenticate with Lemmy API and return user data with JWT - * @throws PlatformAuthException - */ - public function authenticate(string $instanceUrl, string $username, string $password): array - { - try { - $api = new LemmyApiService($instanceUrl); - $token = $api->login($username, $password); - - if (!$token) { - // Throw a clean exception that will be caught and handled by the controller - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials'); - } - - // Get user info with the token - // For now, we'll return a basic response structure - // In a real implementation, you might want to fetch user details - return [ - 'jwt' => $token, - 'person_view' => [ - 'person' => [ - 'id' => 0, // Would need API call to get actual user info - 'display_name' => null, - 'bio' => null, - ] - ] - ]; - } catch (PlatformAuthException $e) { - // Re-throw PlatformAuthExceptions as-is to avoid nesting - throw $e; - } catch (Exception $e) { - // Check if it's a rate limit error - if (str_contains($e->getMessage(), 'Rate limited by')) { - throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage()); - } - // For other exceptions, throw a clean PlatformAuthException - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed'); - } - } -} diff --git a/app/Services/OnboardingService.php b/app/Services/OnboardingService.php deleted file mode 100644 index 2d5dbda..0000000 --- a/app/Services/OnboardingService.php +++ /dev/null @@ -1,46 +0,0 @@ -checkOnboardingStatus(); - }); - } - - public function clearCache(): void - { - Cache::forget('onboarding_needed'); - } - - private function checkOnboardingStatus(): bool - { - $onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true'; - $onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists(); - - // If skipped or completed, no onboarding needed - if ($onboardingCompleted || $onboardingSkipped) { - return false; - } - - // Check if all components exist - $hasPlatformAccount = PlatformAccount::where('is_active', true)->exists(); - $hasFeed = Feed::where('is_active', true)->exists(); - $hasChannel = PlatformChannel::where('is_active', true)->exists(); - $hasRoute = Route::where('is_active', true)->exists(); - - $hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute; - - return !$hasAllComponents; - } -} \ No newline at end of file diff --git a/app/Services/Parsers/BelgaHomepageParser.php b/app/Services/Parsers/BelgaHomepageParser.php deleted file mode 100644 index 8234582..0000000 --- a/app/Services/Parsers/BelgaHomepageParser.php +++ /dev/null @@ -1,48 +0,0 @@ - - */ - public static function extractArticleUrls(string $html): array - { - // Find all relative article links (most articles use relative paths) - preg_match_all('/]+href="(\/[a-z0-9-]+)"/', $html, $matches); - - // Blacklist of non-article paths - $blacklistPaths = [ - '/', - '/de', - '/feed', - '/search', - '/category', - '/about', - '/contact', - '/privacy', - '/terms', - ]; - - $urls = collect($matches[1]) - ->unique() - ->filter(function ($path) use ($blacklistPaths) { - // Exclude exact matches and paths starting with blacklisted paths - foreach ($blacklistPaths as $blacklistedPath) { - if ($path === $blacklistedPath || str_starts_with($path, $blacklistedPath . '/')) { - return false; - } - } - return true; - }) - ->map(function ($path) { - // Convert relative paths to absolute URLs - return 'https://www.belganewsagency.eu' . $path; - }) - ->values() - ->toArray(); - - return $urls; - } -} \ No newline at end of file diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php deleted file mode 100644 index 26c12dc..0000000 --- a/app/Services/Publishing/ArticlePublishingService.php +++ /dev/null @@ -1,143 +0,0 @@ - $extractedData - * @return Collection - * @throws PublishException - */ - public function publishToRoutedChannels(Article $article, array $extractedData): Collection - { - if (! $article->isValid()) { - throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE')); - } - - $feed = $article->feed; - - // Get active routes with keywords instead of just channels - $activeRoutes = Route::where('feed_id', $feed->id) - ->where('is_active', true) - ->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords']) - ->orderBy('priority', 'desc') - ->get(); - - // Filter routes based on keyword matches - $matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) { - return $this->routeMatchesArticle($route, $extractedData); - }); - - return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) { - $channel = $route->platformChannel; - $account = $channel->activePlatformAccounts()->first(); - - if (! $account) { - $this->logSaver->warning('No active account for channel', $channel, [ - 'article_id' => $article->id, - 'route_priority' => $route->priority - ]); - - return null; - } - - return $this->publishToChannel($article, $extractedData, $channel, $account); - }) - ->filter(); - } - - /** - * Check if a route matches an article based on keywords - * @param array $extractedData - */ - private function routeMatchesArticle(Route $route, array $extractedData): bool - { - // Get active keywords for this route - $activeKeywords = $route->keywords->where('is_active', true); - - // If no keywords are defined for this route, the route matches any article - if ($activeKeywords->isEmpty()) { - return true; - } - - // Get article content for keyword matching - $articleContent = ''; - if (isset($extractedData['full_article'])) { - $articleContent = $extractedData['full_article']; - } - if (isset($extractedData['title'])) { - $articleContent .= ' ' . $extractedData['title']; - } - if (isset($extractedData['description'])) { - $articleContent .= ' ' . $extractedData['description']; - } - - // Check if any of the route's keywords match the article content - foreach ($activeKeywords as $keywordModel) { - $keyword = $keywordModel->keyword; - if (stripos($articleContent, $keyword) !== false) { - return true; - } - } - - return false; - } - - /** - * @param array $extractedData - */ - private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication - { - try { - $publisher = $this->makePublisher($account); - $postData = $publisher->publishToChannel($article, $extractedData, $channel); - - $publication = ArticlePublication::create([ - 'article_id' => $article->id, - 'post_id' => $postData['post_view']['post']['id'], - 'platform_channel_id' => $channel->id, - 'published_by' => $account->username, - 'published_at' => now(), - 'platform' => $channel->platformInstance->platform->value, - 'publication_data' => $postData, - ]); - - $this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [ - 'article_id' => $article->id - ]); - - return $publication; - } catch (Exception $e) { - $this->logSaver->warning('Failed to publish to channel', $channel, [ - 'article_id' => $article->id, - 'error' => $e->getMessage() - ]); - - return null; - } - } -} diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php deleted file mode 100644 index de0d46f..0000000 --- a/app/View/Components/AppLayout.php +++ /dev/null @@ -1,17 +0,0 @@ -approve('manual'); - + return $this->sendResponse( new ArticleResource($article->fresh(['feed', 'articlePublication'])), 'Article approved and queued for publishing.' ); - } catch (Exception $e) { + } catch (\Exception $e) { return $this->sendError('Failed to approve article: ' . $e->getMessage(), [], 500); } } @@ -65,30 +62,13 @@ public function reject(Article $article): JsonResponse { try { $article->reject('manual'); - + return $this->sendResponse( new ArticleResource($article->fresh(['feed', 'articlePublication'])), 'Article rejected.' ); - } catch (Exception $e) { + } catch (\Exception $e) { return $this->sendError('Failed to reject article: ' . $e->getMessage(), [], 500); } } - - /** - * Manually refresh articles from all active feeds - */ - public function refresh(): JsonResponse - { - try { - ArticleDiscoveryJob::dispatch(); - - return $this->sendResponse( - null, - 'Article refresh started. New articles will appear shortly.' - ); - } catch (Exception $e) { - return $this->sendError('Failed to start article refresh: ' . $e->getMessage(), [], 500); - } - } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/AuthController.php b/backend/app/Http/Controllers/Api/V1/AuthController.php similarity index 100% rename from app/Http/Controllers/Api/V1/AuthController.php rename to backend/app/Http/Controllers/Api/V1/AuthController.php diff --git a/app/Http/Controllers/Api/V1/BaseController.php b/backend/app/Http/Controllers/Api/V1/BaseController.php similarity index 100% rename from app/Http/Controllers/Api/V1/BaseController.php rename to backend/app/Http/Controllers/Api/V1/BaseController.php diff --git a/app/Http/Controllers/Api/V1/DashboardController.php b/backend/app/Http/Controllers/Api/V1/DashboardController.php similarity index 96% rename from app/Http/Controllers/Api/V1/DashboardController.php rename to backend/app/Http/Controllers/Api/V1/DashboardController.php index 4410879..c8b9399 100644 --- a/app/Http/Controllers/Api/V1/DashboardController.php +++ b/backend/app/Http/Controllers/Api/V1/DashboardController.php @@ -22,17 +22,17 @@ public function __construct( public function stats(Request $request): JsonResponse { $period = $request->get('period', 'today'); - + try { // Get article stats from service $articleStats = $this->dashboardStatsService->getStats($period); - + // Get system stats $systemStats = $this->dashboardStatsService->getSystemStats(); - + // Get available periods $availablePeriods = $this->dashboardStatsService->getAvailablePeriods(); - + return $this->sendResponse([ 'article_stats' => $articleStats, 'system_stats' => $systemStats, @@ -40,8 +40,7 @@ public function stats(Request $request): JsonResponse 'current_period' => $period, ]); } catch (\Exception $e) { - throw $e; return $this->sendError('Failed to fetch dashboard stats: ' . $e->getMessage(), [], 500); } } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/FeedsController.php b/backend/app/Http/Controllers/Api/V1/FeedsController.php similarity index 90% rename from app/Http/Controllers/Api/V1/FeedsController.php rename to backend/app/Http/Controllers/Api/V1/FeedsController.php index f8b07f0..2ff0ad1 100644 --- a/app/Http/Controllers/Api/V1/FeedsController.php +++ b/backend/app/Http/Controllers/Api/V1/FeedsController.php @@ -47,16 +47,6 @@ public function store(StoreFeedRequest $request): JsonResponse $validated = $request->validated(); $validated['is_active'] = $validated['is_active'] ?? true; - // Map provider to URL and set type - $providers = [ - 'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(), - 'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(), - ]; - - $adapter = $providers[$validated['provider']]; - $validated['url'] = $adapter->getHomepageUrl(); - $validated['type'] = 'website'; - $feed = Feed::create($validated); return $this->sendResponse( diff --git a/app/Http/Controllers/Api/V1/LogsController.php b/backend/app/Http/Controllers/Api/V1/LogsController.php similarity index 55% rename from app/Http/Controllers/Api/V1/LogsController.php rename to backend/app/Http/Controllers/Api/V1/LogsController.php index 7f5867c..e83a311 100644 --- a/app/Http/Controllers/Api/V1/LogsController.php +++ b/backend/app/Http/Controllers/Api/V1/LogsController.php @@ -14,34 +14,22 @@ class LogsController extends BaseController public function index(Request $request): JsonResponse { try { - // Clamp per_page between 1 and 100 and ensure integer - $perPage = (int) $request->query('per_page', 20); - if ($perPage < 1) { - $perPage = 20; - } - $perPage = min($perPage, 100); - - $level = $request->query('level'); - - // Stable ordering: created_at desc, then id desc for deterministic results - $query = Log::orderBy('created_at', 'desc') - ->orderBy('id', 'desc'); - - // Exclude known system/console noise that may appear during test bootstrap - $query->where('message', '!=', 'No active feeds found. Article discovery skipped.'); - + $perPage = min($request->get('per_page', 20), 100); + $level = $request->get('level'); + + $query = Log::orderBy('created_at', 'desc'); + if ($level) { $query->where('level', $level); } - + $logs = $query->paginate($perPage); return $this->sendResponse([ 'logs' => $logs->items(), 'pagination' => [ 'current_page' => $logs->currentPage(), - // Ensure last_page is at least 1 to satisfy empty dataset expectation - 'last_page' => max(1, $logs->lastPage()), + 'last_page' => $logs->lastPage(), 'per_page' => $logs->perPage(), 'total' => $logs->total(), 'from' => $logs->firstItem(), @@ -52,4 +40,4 @@ public function index(Request $request): JsonResponse return $this->sendError('Failed to retrieve logs: ' . $e->getMessage(), [], 500); } } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/PlatformAccountsController.php b/backend/app/Http/Controllers/Api/V1/PlatformAccountsController.php similarity index 100% rename from app/Http/Controllers/Api/V1/PlatformAccountsController.php rename to backend/app/Http/Controllers/Api/V1/PlatformAccountsController.php diff --git a/backend/app/Http/Controllers/Api/V1/PlatformChannelsController.php b/backend/app/Http/Controllers/Api/V1/PlatformChannelsController.php new file mode 100644 index 0000000..9b9381f --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/PlatformChannelsController.php @@ -0,0 +1,133 @@ +orderBy('is_active', 'desc') + ->orderBy('name') + ->get(); + + return $this->sendResponse( + PlatformChannelResource::collection($channels), + 'Platform channels retrieved successfully.' + ); + } + + /** + * Store a newly created platform channel + */ + public function store(Request $request): JsonResponse + { + try { + $validated = $request->validate([ + 'platform_instance_id' => 'required|exists:platform_instances,id', + 'channel_id' => 'required|string|max:255', + 'name' => 'required|string|max:255', + 'display_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $validated['is_active'] = $validated['is_active'] ?? true; + + $channel = PlatformChannel::create($validated); + + return $this->sendResponse( + new PlatformChannelResource($channel->load('platformInstance')), + 'Platform channel created successfully!', + 201 + ); + } catch (ValidationException $e) { + return $this->sendValidationError($e->errors()); + } catch (\Exception $e) { + return $this->sendError('Failed to create platform channel: ' . $e->getMessage(), [], 500); + } + } + + /** + * Display the specified platform channel + */ + public function show(PlatformChannel $platformChannel): JsonResponse + { + return $this->sendResponse( + new PlatformChannelResource($platformChannel->load('platformInstance')), + 'Platform channel retrieved successfully.' + ); + } + + /** + * Update the specified platform channel + */ + public function update(Request $request, PlatformChannel $platformChannel): JsonResponse + { + try { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'display_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $platformChannel->update($validated); + + return $this->sendResponse( + new PlatformChannelResource($platformChannel->fresh(['platformInstance'])), + 'Platform channel updated successfully!' + ); + } catch (ValidationException $e) { + return $this->sendValidationError($e->errors()); + } catch (\Exception $e) { + return $this->sendError('Failed to update platform channel: ' . $e->getMessage(), [], 500); + } + } + + /** + * Remove the specified platform channel + */ + public function destroy(PlatformChannel $platformChannel): JsonResponse + { + try { + $platformChannel->delete(); + + return $this->sendResponse( + null, + 'Platform channel deleted successfully!' + ); + } catch (\Exception $e) { + return $this->sendError('Failed to delete platform channel: ' . $e->getMessage(), [], 500); + } + } + + /** + * Toggle platform channel active status + */ + public function toggle(PlatformChannel $channel): JsonResponse + { + try { + $newStatus = !$channel->is_active; + $channel->update(['is_active' => $newStatus]); + + $status = $newStatus ? 'activated' : 'deactivated'; + + return $this->sendResponse( + new PlatformChannelResource($channel->fresh(['platformInstance'])), + "Platform channel {$status} successfully!" + ); + } catch (\Exception $e) { + return $this->sendError('Failed to toggle platform channel status: ' . $e->getMessage(), [], 500); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/RoutingController.php b/backend/app/Http/Controllers/Api/V1/RoutingController.php similarity index 96% rename from app/Http/Controllers/Api/V1/RoutingController.php rename to backend/app/Http/Controllers/Api/V1/RoutingController.php index 1693cd8..05555ac 100644 --- a/app/Http/Controllers/Api/V1/RoutingController.php +++ b/backend/app/Http/Controllers/Api/V1/RoutingController.php @@ -17,7 +17,7 @@ class RoutingController extends BaseController */ public function index(): JsonResponse { - $routes = Route::with(['feed', 'platformChannel', 'keywords']) + $routes = Route::with(['feed', 'platformChannel']) ->orderBy('is_active', 'desc') ->orderBy('priority', 'asc') ->get(); @@ -47,7 +47,7 @@ public function store(Request $request): JsonResponse $route = Route::create($validated); return $this->sendResponse( - new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])), + new RouteResource($route->load(['feed', 'platformChannel'])), 'Routing configuration created successfully!', 201 ); @@ -69,7 +69,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse return $this->sendNotFound('Routing configuration not found.'); } - $route->load(['feed', 'platformChannel', 'keywords']); + $route->load(['feed', 'platformChannel']); return $this->sendResponse( new RouteResource($route), @@ -99,7 +99,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel): ->update($validated); return $this->sendResponse( - new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])), + new RouteResource($route->fresh(['feed', 'platformChannel'])), 'Routing configuration updated successfully!' ); } catch (ValidationException $e) { @@ -154,7 +154,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse $status = $newStatus ? 'activated' : 'deactivated'; return $this->sendResponse( - new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])), + new RouteResource($route->fresh(['feed', 'platformChannel'])), "Routing configuration {$status} successfully!" ); } catch (\Exception $e) { diff --git a/app/Http/Controllers/Api/V1/SettingsController.php b/backend/app/Http/Controllers/Api/V1/SettingsController.php similarity index 100% rename from app/Http/Controllers/Api/V1/SettingsController.php rename to backend/app/Http/Controllers/Api/V1/SettingsController.php diff --git a/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php similarity index 100% rename from app/Http/Controllers/Controller.php rename to backend/app/Http/Controllers/Controller.php diff --git a/app/Http/Middleware/HandleAppearance.php b/backend/app/Http/Middleware/HandleAppearance.php similarity index 100% rename from app/Http/Middleware/HandleAppearance.php rename to backend/app/Http/Middleware/HandleAppearance.php diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/backend/app/Http/Middleware/HandleInertiaRequests.php similarity index 100% rename from app/Http/Middleware/HandleInertiaRequests.php rename to backend/app/Http/Middleware/HandleInertiaRequests.php diff --git a/app/Http/Requests/StoreFeedRequest.php b/backend/app/Http/Requests/StoreFeedRequest.php similarity index 83% rename from app/Http/Requests/StoreFeedRequest.php rename to backend/app/Http/Requests/StoreFeedRequest.php index a49570c..e5c5390 100644 --- a/app/Http/Requests/StoreFeedRequest.php +++ b/backend/app/Http/Requests/StoreFeedRequest.php @@ -18,7 +18,8 @@ public function rules(): array { return [ 'name' => 'required|string|max:255', - 'provider' => 'required|in:vrt,belga', + 'url' => 'required|url|unique:feeds,url', + 'type' => 'required|in:website,rss', 'language_id' => 'required|exists:languages,id', 'description' => 'nullable|string', 'is_active' => 'boolean' diff --git a/app/Http/Requests/UpdateFeedRequest.php b/backend/app/Http/Requests/UpdateFeedRequest.php similarity index 100% rename from app/Http/Requests/UpdateFeedRequest.php rename to backend/app/Http/Requests/UpdateFeedRequest.php diff --git a/app/Http/Resources/ArticlePublicationResource.php b/backend/app/Http/Resources/ArticlePublicationResource.php similarity index 100% rename from app/Http/Resources/ArticlePublicationResource.php rename to backend/app/Http/Resources/ArticlePublicationResource.php diff --git a/app/Http/Resources/ArticleResource.php b/backend/app/Http/Resources/ArticleResource.php similarity index 88% rename from app/Http/Resources/ArticleResource.php rename to backend/app/Http/Resources/ArticleResource.php index 506cf14..653eb41 100644 --- a/app/Http/Resources/ArticleResource.php +++ b/backend/app/Http/Resources/ArticleResource.php @@ -5,11 +5,13 @@ use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -/** - * @property int $id - */ class ArticleResource extends JsonResource { + /** + * Transform the resource into an array. + * + * @return array + */ public function toArray(Request $request): array { return [ @@ -25,11 +27,10 @@ public function toArray(Request $request): array 'approved_by' => $this->approved_by, 'fetched_at' => $this->fetched_at?->toISOString(), 'validated_at' => $this->validated_at?->toISOString(), - 'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null, 'created_at' => $this->created_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(), 'feed' => new FeedResource($this->whenLoaded('feed')), 'article_publication' => new ArticlePublicationResource($this->whenLoaded('articlePublication')), ]; } -} +} \ No newline at end of file diff --git a/app/Http/Resources/FeedResource.php b/backend/app/Http/Resources/FeedResource.php similarity index 90% rename from app/Http/Resources/FeedResource.php rename to backend/app/Http/Resources/FeedResource.php index c220d40..ac5641b 100644 --- a/app/Http/Resources/FeedResource.php +++ b/backend/app/Http/Resources/FeedResource.php @@ -19,8 +19,6 @@ public function toArray(Request $request): array 'name' => $this->name, 'url' => $this->url, 'type' => $this->type, - 'provider' => $this->provider, - 'language_id' => $this->language_id, 'is_active' => $this->is_active, 'description' => $this->description, 'created_at' => $this->created_at->toISOString(), diff --git a/app/Http/Resources/PlatformAccountResource.php b/backend/app/Http/Resources/PlatformAccountResource.php similarity index 100% rename from app/Http/Resources/PlatformAccountResource.php rename to backend/app/Http/Resources/PlatformAccountResource.php diff --git a/app/Http/Resources/PlatformChannelResource.php b/backend/app/Http/Resources/PlatformChannelResource.php similarity index 86% rename from app/Http/Resources/PlatformChannelResource.php rename to backend/app/Http/Resources/PlatformChannelResource.php index cdebaa4..3024891 100644 --- a/app/Http/Resources/PlatformChannelResource.php +++ b/backend/app/Http/Resources/PlatformChannelResource.php @@ -21,12 +21,10 @@ public function toArray(Request $request): array 'name' => $this->name, 'display_name' => $this->display_name, 'description' => $this->description, - 'language_id' => $this->language_id, 'is_active' => $this->is_active, 'created_at' => $this->created_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(), 'platform_instance' => new PlatformInstanceResource($this->whenLoaded('platformInstance')), - 'platform_accounts' => PlatformAccountResource::collection($this->whenLoaded('platformAccounts')), 'routes' => RouteResource::collection($this->whenLoaded('routes')), ]; } diff --git a/app/Http/Resources/PlatformInstanceResource.php b/backend/app/Http/Resources/PlatformInstanceResource.php similarity index 100% rename from app/Http/Resources/PlatformInstanceResource.php rename to backend/app/Http/Resources/PlatformInstanceResource.php diff --git a/app/Http/Resources/RouteResource.php b/backend/app/Http/Resources/RouteResource.php similarity index 69% rename from app/Http/Resources/RouteResource.php rename to backend/app/Http/Resources/RouteResource.php index 6a02c8d..08d38af 100644 --- a/app/Http/Resources/RouteResource.php +++ b/backend/app/Http/Resources/RouteResource.php @@ -24,15 +24,6 @@ public function toArray(Request $request): array 'updated_at' => $this->updated_at->toISOString(), 'feed' => new FeedResource($this->whenLoaded('feed')), 'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')), - 'keywords' => $this->whenLoaded('keywords', function () { - return $this->keywords->map(function ($keyword) { - return [ - 'id' => $keyword->id, - 'keyword' => $keyword->keyword, - 'is_active' => $keyword->is_active, - ]; - }); - }), ]; } } \ No newline at end of file diff --git a/app/Jobs/ArticleDiscoveryForFeedJob.php b/backend/app/Jobs/ArticleDiscoveryForFeedJob.php similarity index 75% rename from app/Jobs/ArticleDiscoveryForFeedJob.php rename to backend/app/Jobs/ArticleDiscoveryForFeedJob.php index db494a6..ac26406 100644 --- a/app/Jobs/ArticleDiscoveryForFeedJob.php +++ b/backend/app/Jobs/ArticleDiscoveryForFeedJob.php @@ -20,17 +20,17 @@ public function __construct( $this->onQueue('feed-discovery'); } - public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void + public function handle(): void { - $logSaver->info('Starting feed article fetch', null, [ + LogSaver::info('Starting feed article fetch', null, [ 'feed_id' => $this->feed->id, 'feed_name' => $this->feed->name, 'feed_url' => $this->feed->url ]); - $articles = $articleFetcher->getArticlesFromFeed($this->feed); + $articles = ArticleFetcher::getArticlesFromFeed($this->feed); - $logSaver->info('Feed article fetch completed', null, [ + LogSaver::info('Feed article fetch completed', null, [ 'feed_id' => $this->feed->id, 'feed_name' => $this->feed->name, 'articles_count' => $articles->count() @@ -41,11 +41,9 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void public static function dispatchForAllActiveFeeds(): void { - $logSaver = app(LogSaver::class); - Feed::where('is_active', true) ->get() - ->each(function (Feed $feed, $index) use ($logSaver) { + ->each(function (Feed $feed, $index) { // Space jobs apart to avoid overwhelming feeds $delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES; @@ -53,7 +51,7 @@ public static function dispatchForAllActiveFeeds(): void ->delay(now()->addMinutes($delayMinutes)) ->onQueue('feed-discovery'); - $logSaver->info('Dispatched feed discovery job', null, [ + LogSaver::info('Dispatched feed discovery job', null, [ 'feed_id' => $feed->id, 'feed_name' => $feed->name, 'delay_minutes' => $delayMinutes diff --git a/app/Jobs/ArticleDiscoveryJob.php b/backend/app/Jobs/ArticleDiscoveryJob.php similarity index 62% rename from app/Jobs/ArticleDiscoveryJob.php rename to backend/app/Jobs/ArticleDiscoveryJob.php index c89894e..5ea8476 100644 --- a/app/Jobs/ArticleDiscoveryJob.php +++ b/backend/app/Jobs/ArticleDiscoveryJob.php @@ -16,18 +16,18 @@ public function __construct() $this->onQueue('feed-discovery'); } - public function handle(LogSaver $logSaver): void + public function handle(): void { if (!Setting::isArticleProcessingEnabled()) { - $logSaver->info('Article processing is disabled. Article discovery skipped.'); + LogSaver::info('Article processing is disabled. Article discovery skipped.'); return; } - $logSaver->info('Starting article discovery for all active feeds'); + LogSaver::info('Starting article discovery for all active feeds'); ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - $logSaver->info('Article discovery jobs dispatched for all active feeds'); + LogSaver::info('Article discovery jobs dispatched for all active feeds'); } } diff --git a/backend/app/Jobs/PublishToLemmyJob.php b/backend/app/Jobs/PublishToLemmyJob.php new file mode 100644 index 0000000..58cd317 --- /dev/null +++ b/backend/app/Jobs/PublishToLemmyJob.php @@ -0,0 +1,38 @@ +onQueue('lemmy-posts'); + } + + public function handle(): void + { + $extractedData = ArticleFetcher::fetchArticleData($this->article); + + /** @var ArticlePublishingService $publishingService */ + $publishingService = resolve(ArticlePublishingService::class); + + try { + $publishingService->publishToRoutedChannels($this->article, $extractedData); + } catch (PublishException $e) { + $this->fail($e); + } + } +} diff --git a/app/Jobs/SyncChannelPostsJob.php b/backend/app/Jobs/SyncChannelPostsJob.php similarity index 80% rename from app/Jobs/SyncChannelPostsJob.php rename to backend/app/Jobs/SyncChannelPostsJob.php index 2f5bee6..5f40503 100644 --- a/app/Jobs/SyncChannelPostsJob.php +++ b/backend/app/Jobs/SyncChannelPostsJob.php @@ -27,34 +27,32 @@ public function __construct( public static function dispatchForAllActiveChannels(): void { - $logSaver = app(LogSaver::class); - PlatformChannel::with(['platformInstance', 'platformAccounts']) ->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY)) - ->whereHas('platformAccounts', fn ($query) => $query->where('platform_accounts.is_active', true)) - ->where('platform_channels.is_active', true) + ->whereHas('platformAccounts', fn ($query) => $query->where('is_active', true)) + ->where('is_active', true) ->get() - ->each(function (PlatformChannel $channel) use ($logSaver) { + ->each(function (PlatformChannel $channel) { self::dispatch($channel); - $logSaver->info('Dispatched sync job for channel', $channel); + LogSaver::info('Dispatched sync job for channel', $channel); }); } - public function handle(LogSaver $logSaver): void + public function handle(): void { - $logSaver->info('Starting channel posts sync job', $this->channel); + LogSaver::info('Starting channel posts sync job', $this->channel); match ($this->channel->platformInstance->platform) { - PlatformEnum::LEMMY => $this->syncLemmyChannelPosts($logSaver), + PlatformEnum::LEMMY => $this->syncLemmyChannelPosts(), }; - $logSaver->info('Channel posts sync job completed', $this->channel); + LogSaver::info('Channel posts sync job completed', $this->channel); } /** * @throws PlatformAuthException */ - private function syncLemmyChannelPosts(LogSaver $logSaver): void + private function syncLemmyChannelPosts(): void { try { /** @var Collection $accounts */ @@ -74,10 +72,10 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void $api->syncChannelPosts($token, $platformChannelId, $this->channel->name); - $logSaver->info('Channel posts synced successfully', $this->channel); + LogSaver::info('Channel posts synced successfully', $this->channel); } catch (Exception $e) { - $logSaver->error('Failed to sync channel posts', $this->channel, [ + LogSaver::error('Failed to sync channel posts', $this->channel, [ 'error' => $e->getMessage() ]); diff --git a/backend/app/Listeners/LogExceptionToDatabase.php b/backend/app/Listeners/LogExceptionToDatabase.php new file mode 100644 index 0000000..23da3a2 --- /dev/null +++ b/backend/app/Listeners/LogExceptionToDatabase.php @@ -0,0 +1,27 @@ + $event->level, + 'message' => $event->message, + 'context' => [ + 'exception_class' => get_class($event->exception), + 'file' => $event->exception->getFile(), + 'line' => $event->exception->getLine(), + 'trace' => $event->exception->getTraceAsString(), + ...$event->context + ] + ]); + + ExceptionLogged::dispatch($log); + } +} diff --git a/backend/app/Listeners/PublishApprovedArticle.php b/backend/app/Listeners/PublishApprovedArticle.php new file mode 100644 index 0000000..8ff3e31 --- /dev/null +++ b/backend/app/Listeners/PublishApprovedArticle.php @@ -0,0 +1,27 @@ +article; + + // Skip if already has publication (prevents duplicate processing) + if ($article->articlePublication()->exists()) { + return; + } + + // Only publish if the article is valid and approved + if ($article->isValid() && $article->isApproved()) { + event(new ArticleReadyToPublish($article)); + } + } +} diff --git a/backend/app/Listeners/PublishArticle.php b/backend/app/Listeners/PublishArticle.php new file mode 100644 index 0000000..7c3d98d --- /dev/null +++ b/backend/app/Listeners/PublishArticle.php @@ -0,0 +1,39 @@ +article; + + if ($article->articlePublication()->exists()) { + logger()->info('Article already published, skipping job dispatch', [ + 'article_id' => $article->id, + 'url' => $article->url + ]); + + return; + } + + logger()->info('Article queued for publishing to Lemmy', [ + 'article_id' => $article->id, + 'url' => $article->url + ]); + + PublishToLemmyJob::dispatch($article); + } +} diff --git a/app/Listeners/ValidateArticleListener.php b/backend/app/Listeners/ValidateArticleListener.php similarity index 80% rename from app/Listeners/ValidateArticleListener.php rename to backend/app/Listeners/ValidateArticleListener.php index 9c5ebcf..842d303 100644 --- a/app/Listeners/ValidateArticleListener.php +++ b/backend/app/Listeners/ValidateArticleListener.php @@ -3,7 +3,7 @@ namespace App\Listeners; use App\Events\NewArticleFetched; -use App\Events\ArticleApproved; +use App\Events\ArticleReadyToPublish; use App\Models\Setting; use App\Services\Article\ValidationService; use Illuminate\Contracts\Queue\ShouldQueue; @@ -12,7 +12,7 @@ class ValidateArticleListener implements ShouldQueue { public string $queue = 'default'; - public function handle(NewArticleFetched $event, ValidationService $validationService): void + public function handle(NewArticleFetched $event): void { $article = $event->article; @@ -25,7 +25,7 @@ public function handle(NewArticleFetched $event, ValidationService $validationSe return; } - $article = $validationService->validate($article); + $article = ValidationService::validate($article); if ($article->isValid()) { // Double-check publication doesn't exist (race condition protection) @@ -37,12 +37,12 @@ public function handle(NewArticleFetched $event, ValidationService $validationSe if (Setting::isPublishingApprovalsEnabled()) { // If approvals are enabled, only proceed if article is approved if ($article->isApproved()) { - event(new ArticleApproved($article)); + event(new ArticleReadyToPublish($article)); } // If not approved, article will wait for manual approval } else { // If approvals are disabled, proceed with publishing - event(new ArticleApproved($article)); + event(new ArticleReadyToPublish($article)); } } } diff --git a/app/Models/Article.php b/backend/app/Models/Article.php similarity index 81% rename from app/Models/Article.php rename to backend/app/Models/Article.php index fc66794..b5f87dc 100644 --- a/app/Models/Article.php +++ b/backend/app/Models/Article.php @@ -35,11 +35,13 @@ class Article extends Model 'url', 'title', 'description', - 'content', - 'image_url', - 'published_at', - 'author', + 'is_valid', + 'is_duplicate', 'approval_status', + 'approved_at', + 'approved_by', + 'fetched_at', + 'validated_at', ]; /** @@ -48,8 +50,12 @@ class Article extends Model public function casts(): array { return [ + 'is_valid' => 'boolean', + 'is_duplicate' => 'boolean', 'approval_status' => 'string', - 'published_at' => 'datetime', + 'approved_at' => 'datetime', + 'fetched_at' => 'datetime', + 'validated_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; @@ -57,9 +63,15 @@ public function casts(): array public function isValid(): bool { - // In the consolidated schema, we only have approval_status - // Consider 'approved' status as valid - return $this->approval_status === 'approved'; + if (is_null($this->validated_at)) { + return false; + } + + if (is_null($this->is_valid)) { + return false; + } + + return $this->is_valid; } public function isApproved(): bool @@ -81,6 +93,8 @@ public function approve(string $approvedBy = null): void { $this->update([ 'approval_status' => 'approved', + 'approved_at' => now(), + 'approved_by' => $approvedBy, ]); // Fire event to trigger publishing @@ -91,6 +105,8 @@ public function reject(string $rejectedBy = null): void { $this->update([ 'approval_status' => 'rejected', + 'approved_at' => now(), + 'approved_by' => $rejectedBy, ]); } @@ -109,11 +125,6 @@ public function canBePublished(): bool return $this->isApproved(); } - public function getIsPublishedAttribute(): bool - { - return $this->articlePublication()->exists(); - } - /** * @return HasOne */ diff --git a/app/Models/ArticlePublication.php b/backend/app/Models/ArticlePublication.php similarity index 100% rename from app/Models/ArticlePublication.php rename to backend/app/Models/ArticlePublication.php diff --git a/app/Models/Feed.php b/backend/app/Models/Feed.php similarity index 96% rename from app/Models/Feed.php rename to backend/app/Models/Feed.php index 6fefbec..5e543e7 100644 --- a/app/Models/Feed.php +++ b/backend/app/Models/Feed.php @@ -15,7 +15,6 @@ * @property string $name * @property string $url * @property string $type - * @property string $provider * @property int $language_id * @property Language|null $language * @property string $description @@ -39,7 +38,6 @@ class Feed extends Model 'name', 'url', 'type', - 'provider', 'language_id', 'description', 'settings', @@ -89,7 +87,8 @@ public function getStatusAttribute(): string public function channels(): BelongsToMany { return $this->belongsToMany(PlatformChannel::class, 'routes') - ->withPivot(['is_active', 'priority']) + ->using(Route::class) + ->withPivot(['is_active', 'priority', 'filters']) ->withTimestamps(); } diff --git a/app/Models/Keyword.php b/backend/app/Models/Keyword.php similarity index 100% rename from app/Models/Keyword.php rename to backend/app/Models/Keyword.php diff --git a/app/Models/Language.php b/backend/app/Models/Language.php similarity index 100% rename from app/Models/Language.php rename to backend/app/Models/Language.php diff --git a/app/Models/Log.php b/backend/app/Models/Log.php similarity index 100% rename from app/Models/Log.php rename to backend/app/Models/Log.php diff --git a/app/Models/PlatformAccount.php b/backend/app/Models/PlatformAccount.php similarity index 73% rename from app/Models/PlatformAccount.php rename to backend/app/Models/PlatformAccount.php index ca309e9..c450ede 100644 --- a/app/Models/PlatformAccount.php +++ b/backend/app/Models/PlatformAccount.php @@ -39,6 +39,7 @@ class PlatformAccount extends Model 'instance_url', 'username', 'password', + 'api_token', 'settings', 'is_active', 'last_tested_at', @@ -59,40 +60,22 @@ class PlatformAccount extends Model protected function password(): Attribute { return Attribute::make( - get: function ($value, array $attributes) { - // Return null if the raw value is null - if (is_null($value)) { - return null; - } - - // Return empty string if value is empty - if (empty($value)) { - return ''; - } - - try { - return Crypt::decryptString($value); - } catch (\Exception $e) { - // If decryption fails, return null to be safe - return null; - } - }, - set: function ($value) { - // Store null if null is passed - if (is_null($value)) { - return null; - } - - // Store empty string as null - if (empty($value)) { - return null; - } - - return Crypt::encryptString($value); - }, - )->withoutObjectCaching(); + get: fn ($value) => $value ? Crypt::decryptString($value) : null, + set: fn ($value) => $value ? Crypt::encryptString($value) : null, + ); } + // Encrypt API token when storing + /** + * @return Attribute + */ + protected function apiToken(): Attribute + { + return Attribute::make( + get: fn ($value) => $value ? Crypt::decryptString($value) : null, + set: fn ($value) => $value ? Crypt::encryptString($value) : null, + ); + } // Get the active accounts for a platform (returns collection) /** diff --git a/app/Models/PlatformChannel.php b/backend/app/Models/PlatformChannel.php similarity index 96% rename from app/Models/PlatformChannel.php rename to backend/app/Models/PlatformChannel.php index 5179d0f..2e0b4bf 100644 --- a/app/Models/PlatformChannel.php +++ b/backend/app/Models/PlatformChannel.php @@ -10,7 +10,6 @@ /** * @method static findMany(mixed $channel_ids) - * @method static create(array $array) * @property integer $id * @property integer $platform_instance_id * @property PlatformInstance $platformInstance @@ -79,7 +78,8 @@ public function getFullNameAttribute(): string public function feeds(): BelongsToMany { return $this->belongsToMany(Feed::class, 'routes') - ->withPivot(['is_active', 'priority']) + ->using(Route::class) + ->withPivot(['is_active', 'priority', 'filters']) ->withTimestamps(); } diff --git a/app/Models/PlatformChannelPost.php b/backend/app/Models/PlatformChannelPost.php similarity index 95% rename from app/Models/PlatformChannelPost.php rename to backend/app/Models/PlatformChannelPost.php index ef6a21d..d774a4f 100644 --- a/app/Models/PlatformChannelPost.php +++ b/backend/app/Models/PlatformChannelPost.php @@ -3,7 +3,6 @@ namespace App\Models; use App\Enums\PlatformEnum; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; /** @@ -12,7 +11,6 @@ */ class PlatformChannelPost extends Model { - use HasFactory; protected $fillable = [ 'platform', 'channel_id', diff --git a/app/Models/PlatformInstance.php b/backend/app/Models/PlatformInstance.php similarity index 100% rename from app/Models/PlatformInstance.php rename to backend/app/Models/PlatformInstance.php diff --git a/app/Models/Route.php b/backend/app/Models/Route.php similarity index 91% rename from app/Models/Route.php rename to backend/app/Models/Route.php index b5ee7d0..c18ed38 100644 --- a/app/Models/Route.php +++ b/backend/app/Models/Route.php @@ -14,6 +14,7 @@ * @property int $platform_channel_id * @property bool $is_active * @property int $priority + * @property array $filters * @property Carbon $created_at * @property Carbon $updated_at */ @@ -32,11 +33,13 @@ class Route extends Model 'feed_id', 'platform_channel_id', 'is_active', - 'priority' + 'priority', + 'filters' ]; protected $casts = [ - 'is_active' => 'boolean' + 'is_active' => 'boolean', + 'filters' => 'array' ]; /** diff --git a/app/Models/Setting.php b/backend/app/Models/Setting.php similarity index 100% rename from app/Models/Setting.php rename to backend/app/Models/Setting.php diff --git a/app/Models/User.php b/backend/app/Models/User.php similarity index 100% rename from app/Models/User.php rename to backend/app/Models/User.php diff --git a/backend/app/Modules/Lemmy/LemmyRequest.php b/backend/app/Modules/Lemmy/LemmyRequest.php new file mode 100644 index 0000000..5bdb5d8 --- /dev/null +++ b/backend/app/Modules/Lemmy/LemmyRequest.php @@ -0,0 +1,56 @@ +instance = $instance; + $this->token = $token; + } + + /** + * @param array $params + */ + public function get(string $endpoint, array $params = []): Response + { + $url = "https://{$this->instance}/api/v3/{$endpoint}"; + + $request = Http::timeout(30); + + if ($this->token) { + $request = $request->withToken($this->token); + } + + return $request->get($url, $params); + } + + /** + * @param array $data + */ + public function post(string $endpoint, array $data = []): Response + { + $url = "https://{$this->instance}/api/v3/{$endpoint}"; + + $request = Http::timeout(30); + + if ($this->token) { + $request = $request->withToken($this->token); + } + + return $request->post($url, $data); + } + + public function withToken(string $token): self + { + $this->token = $token; + return $this; + } +} diff --git a/app/Modules/Lemmy/Services/LemmyApiService.php b/backend/app/Modules/Lemmy/Services/LemmyApiService.php similarity index 68% rename from app/Modules/Lemmy/Services/LemmyApiService.php rename to backend/app/Modules/Lemmy/Services/LemmyApiService.php index 3329703..108c431 100644 --- a/app/Modules/Lemmy/Services/LemmyApiService.php +++ b/backend/app/Modules/Lemmy/Services/LemmyApiService.php @@ -18,61 +18,27 @@ public function __construct(string $instance) public function login(string $username, string $password): ?string { - // Try HTTPS first; on failure, optionally retry with HTTP to support dev instances - $schemesToTry = []; - if (preg_match('/^https?:\/\//i', $this->instance)) { - // Preserve user-provided scheme as first try - $schemesToTry[] = strtolower(str_starts_with($this->instance, 'http://') ? 'http' : 'https'); - } else { - // Default order: https then http - $schemesToTry = ['https', 'http']; - } + try { + $request = new LemmyRequest($this->instance); + $response = $request->post('user/login', [ + 'username_or_email' => $username, + 'password' => $password, + ]); - foreach ($schemesToTry as $idx => $scheme) { - try { - $request = new LemmyRequest($this->instance); - // ensure scheme used matches current attempt - $request = $request->withScheme($scheme); - - $response = $request->post('user/login', [ - 'username_or_email' => $username, - 'password' => $password, + if (!$response->successful()) { + logger()->error('Lemmy login failed', [ + 'status' => $response->status(), + 'body' => $response->body() ]); - - if (!$response->successful()) { - $responseBody = $response->body(); - logger()->error('Lemmy login failed', [ - 'status' => $response->status(), - 'body' => $responseBody, - 'scheme' => $scheme, - ]); - - // Check if it's a rate limit error - if (str_contains($responseBody, 'rate_limit_error')) { - throw new Exception('Rate limited by Lemmy instance. Please wait a moment and try again.'); - } - - // If first attempt failed and there is another scheme to try, continue loop - if ($idx === 0 && count($schemesToTry) > 1) { - continue; - } - - return null; - } - - $data = $response->json(); - return $data['jwt'] ?? null; - } catch (Exception $e) { - logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]); - // If this was the first attempt and HTTPS, try HTTP next - if ($idx === 0 && in_array('http', $schemesToTry, true)) { - continue; - } return null; } - } - return null; + $data = $response->json(); + return $data['jwt'] ?? null; + } catch (Exception $e) { + logger()->error('Lemmy login exception', ['error' => $e->getMessage()]); + return null; + } } public function getCommunityId(string $communityName, string $token): int diff --git a/app/Modules/Lemmy/Services/LemmyPublisher.php b/backend/app/Modules/Lemmy/Services/LemmyPublisher.php similarity index 78% rename from app/Modules/Lemmy/Services/LemmyPublisher.php rename to backend/app/Modules/Lemmy/Services/LemmyPublisher.php index c19262f..68a2651 100644 --- a/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/backend/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -28,21 +28,16 @@ public function __construct(PlatformAccount $account) */ public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array { - $token = resolve(LemmyAuthService::class)->getToken($this->account); + $token = LemmyAuthService::getToken($this->account); // Use the language ID from extracted data (should be set during validation) $languageId = $extractedData['language_id'] ?? null; - // Resolve community name to numeric ID if needed - $communityId = is_numeric($channel->channel_id) - ? (int) $channel->channel_id - : $this->api->getCommunityId($channel->channel_id, $token); - return $this->api->createPost( $token, $extractedData['title'] ?? 'Untitled', $extractedData['description'] ?? '', - $communityId, + $channel->channel_id, $article->url, $extractedData['thumbnail'] ?? null, $languageId diff --git a/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php similarity index 82% rename from app/Providers/AppServiceProvider.php rename to backend/app/Providers/AppServiceProvider.php index 1ac3ea4..544ba68 100644 --- a/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -30,6 +30,16 @@ public function boot(): void \App\Listeners\ValidateArticleListener::class, ); + Event::listen( + \App\Events\ArticleApproved::class, + \App\Listeners\PublishApprovedArticle::class, + ); + + Event::listen( + \App\Events\ArticleReadyToPublish::class, + \App\Listeners\PublishArticle::class, + ); + app()->make(ExceptionHandler::class) ->reportable(function (Throwable $e) { diff --git a/app/Providers/HorizonServiceProvider.php b/backend/app/Providers/HorizonServiceProvider.php similarity index 100% rename from app/Providers/HorizonServiceProvider.php rename to backend/app/Providers/HorizonServiceProvider.php diff --git a/app/Services/Article/ArticleFetcher.php b/backend/app/Services/Article/ArticleFetcher.php similarity index 50% rename from app/Services/Article/ArticleFetcher.php rename to backend/app/Services/Article/ArticleFetcher.php index d14e7be..badb899 100644 --- a/app/Services/Article/ArticleFetcher.php +++ b/backend/app/Services/Article/ArticleFetcher.php @@ -13,22 +13,18 @@ class ArticleFetcher { - public function __construct( - private LogSaver $logSaver - ) {} - /** * @return Collection */ - public function getArticlesFromFeed(Feed $feed): Collection + public static function getArticlesFromFeed(Feed $feed): Collection { if ($feed->type === 'rss') { - return $this->getArticlesFromRssFeed($feed); + return self::getArticlesFromRssFeed($feed); } elseif ($feed->type === 'website') { - return $this->getArticlesFromWebsiteFeed($feed); + return self::getArticlesFromWebsiteFeed($feed); } - $this->logSaver->warning("Unsupported feed type", null, [ + LogSaver::warning("Unsupported feed type", null, [ 'feed_id' => $feed->id, 'feed_type' => $feed->type ]); @@ -39,7 +35,7 @@ public function getArticlesFromFeed(Feed $feed): Collection /** * @return Collection */ - private function getArticlesFromRssFeed(Feed $feed): Collection + private static function getArticlesFromRssFeed(Feed $feed): Collection { // TODO: Implement RSS feed parsing // For now, return empty collection @@ -49,14 +45,14 @@ private function getArticlesFromRssFeed(Feed $feed): Collection /** * @return Collection */ - private function getArticlesFromWebsiteFeed(Feed $feed): Collection + private static function getArticlesFromWebsiteFeed(Feed $feed): Collection { try { // Try to get parser for this feed $parser = HomepageParserFactory::getParserForFeed($feed); if (! $parser) { - $this->logSaver->warning("No parser available for feed URL", null, [ + LogSaver::warning("No parser available for feed URL", null, [ 'feed_id' => $feed->id, 'feed_url' => $feed->url ]); @@ -68,10 +64,10 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection $urls = $parser->extractArticleUrls($html); return collect($urls) - ->map(fn (string $url) => $this->saveArticle($url, $feed->id)); + ->map(fn (string $url) => self::saveArticle($url, $feed->id)); } catch (Exception $e) { - $this->logSaver->error("Failed to fetch articles from website feed", null, [ + LogSaver::error("Failed to fetch articles from website feed", null, [ 'feed_id' => $feed->id, 'feed_url' => $feed->url, 'error' => $e->getMessage() @@ -84,7 +80,7 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection /** * @return array */ - public function fetchArticleData(Article $article): array + public static function fetchArticleData(Article $article): array { try { $html = HttpFetcher::fetchHtml($article->url); @@ -92,7 +88,7 @@ public function fetchArticleData(Article $article): array return $parser->extractData($html); } catch (Exception $e) { - $this->logSaver->error('Exception while fetching article data', null, [ + LogSaver::error('Exception while fetching article data', null, [ 'url' => $article->url, 'error' => $e->getMessage() ]); @@ -101,7 +97,7 @@ public function fetchArticleData(Article $article): array } } - private function saveArticle(string $url, ?int $feedId = null): Article + private static function saveArticle(string $url, ?int $feedId = null): Article { $existingArticle = Article::where('url', $url)->first(); @@ -109,37 +105,9 @@ private function saveArticle(string $url, ?int $feedId = null): Article return $existingArticle; } - // Extract a basic title from URL as fallback - $fallbackTitle = $this->generateFallbackTitle($url); - - try { - return Article::create([ - 'url' => $url, - 'feed_id' => $feedId, - 'title' => $fallbackTitle, - ]); - } catch (\Exception $e) { - $this->logSaver->error("Failed to create article - title validation failed", null, [ - 'url' => $url, - 'feed_id' => $feedId, - 'error' => $e->getMessage(), - 'suggestion' => 'Check regex parsing patterns for title extraction' - ]); - throw $e; - } - } - - private function generateFallbackTitle(string $url): string - { - // Extract filename from URL as a basic fallback title - $path = parse_url($url, PHP_URL_PATH); - $filename = basename($path ?: $url); - - // Remove file extension and convert to readable format - $title = preg_replace('/\.[^.]*$/', '', $filename); - $title = str_replace(['-', '_'], ' ', $title); - $title = ucwords($title); - - return $title ?: 'Untitled Article'; + return Article::create([ + 'url' => $url, + 'feed_id' => $feedId + ]); } } diff --git a/backend/app/Services/Article/ValidationService.php b/backend/app/Services/Article/ValidationService.php new file mode 100644 index 0000000..ffff924 --- /dev/null +++ b/backend/app/Services/Article/ValidationService.php @@ -0,0 +1,53 @@ +id); + + $articleData = ArticleFetcher::fetchArticleData($article); + + if (!isset($articleData['full_article'])) { + logger()->warning('Article data missing full_article key', [ + 'article_id' => $article->id, + 'url' => $article->url + ]); + + $article->update([ + 'is_valid' => false, + 'validated_at' => now(), + ]); + + return $article->refresh(); + } + + $validationResult = self::validateByKeywords($articleData['full_article']); + + $article->update([ + 'is_valid' => $validationResult, + 'validated_at' => now(), + ]); + + return $article->refresh(); + } + + private static function validateByKeywords(string $full_article): bool + { + $keywords = [ + 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', + ]; + + foreach ($keywords as $keyword) { + if (stripos($full_article, $keyword) !== false) { + return true; + } + } + + return false; + } +} diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php new file mode 100644 index 0000000..1ee58b2 --- /dev/null +++ b/backend/app/Services/Auth/LemmyAuthService.php @@ -0,0 +1,41 @@ +id"; + $cachedToken = Cache::get($cacheKey); + + if ($cachedToken) { + return $cachedToken; + } + + if (! $account->username || ! $account->password || ! $account->instance_url) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); + } + + $api = new LemmyApiService($account->instance_url); + $token = $api->login($account->username, $account->password); + + if (!$token) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); + } + + // Cache for 50 minutes (3000 seconds) to allow buffer before token expires + Cache::put($cacheKey, $token, 3000); + + return $token; + } +} diff --git a/app/Services/DashboardStatsService.php b/backend/app/Services/DashboardStatsService.php similarity index 64% rename from app/Services/DashboardStatsService.php rename to backend/app/Services/DashboardStatsService.php index a5d8310..02b095f 100644 --- a/app/Services/DashboardStatsService.php +++ b/backend/app/Services/DashboardStatsService.php @@ -4,19 +4,18 @@ use App\Models\Article; use App\Models\ArticlePublication; -use App\Models\Feed; -use App\Models\PlatformAccount; -use App\Models\PlatformChannel; -use App\Models\Route; use Carbon\Carbon; use Illuminate\Support\Facades\DB; class DashboardStatsService { + /** + * @return array + */ public function getStats(string $period = 'today'): array { $dateRange = $this->getDateRange($period); - + // Get articles fetched for the period $articlesFetchedQuery = Article::query(); if ($dateRange) { @@ -62,7 +61,7 @@ public function getAvailablePeriods(): array private function getDateRange(string $period): ?array { $now = Carbon::now(); - + return match ($period) { 'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], 'week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()], @@ -73,26 +72,40 @@ private function getDateRange(string $period): ?array }; } + /** + * Get additional stats for dashboard + */ public function getSystemStats(): array { - $totalFeeds = Feed::query()->count(); - $activeFeeds = Feed::query()->where('is_active', 1)->count(); - $totalPlatformAccounts = PlatformAccount::query()->count(); - $activePlatformAccounts = PlatformAccount::query()->where('is_active', 1)->count(); - $totalPlatformChannels = PlatformChannel::query()->count(); - $activePlatformChannels = PlatformChannel::query()->where('is_active', 1)->count(); - $totalRoutes = Route::query()->count(); - $activeRoutes = Route::query()->where('is_active', 1)->count(); - + // Optimize with single queries using conditional aggregation + $feedStats = DB::table('feeds') + ->selectRaw(' + COUNT(*) as total_feeds, + SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_feeds + ') + ->first(); + + $channelStats = DB::table('platform_channels') + ->selectRaw(' + COUNT(*) as total_channels, + SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_channels + ') + ->first(); + + $routeStats = DB::table('routes') + ->selectRaw(' + COUNT(*) as total_routes, + SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_routes + ') + ->first(); + return [ - 'total_feeds' => $totalFeeds, - 'active_feeds' => $activeFeeds, - 'total_platform_accounts' => $totalPlatformAccounts, - 'active_platform_accounts' => $activePlatformAccounts, - 'total_platform_channels' => $totalPlatformChannels, - 'active_platform_channels' => $activePlatformChannels, - 'total_routes' => $totalRoutes, - 'active_routes' => $activeRoutes, + 'total_feeds' => $feedStats->total_feeds, + 'active_feeds' => $feedStats->active_feeds, + 'total_channels' => $channelStats->total_channels, + 'active_channels' => $channelStats->active_channels, + 'total_routes' => $routeStats->total_routes, + 'active_routes' => $routeStats->active_routes, ]; } -} +} \ No newline at end of file diff --git a/app/Services/Factories/ArticleParserFactory.php b/backend/app/Services/Factories/ArticleParserFactory.php similarity index 69% rename from app/Services/Factories/ArticleParserFactory.php rename to backend/app/Services/Factories/ArticleParserFactory.php index 765994a..682feac 100644 --- a/app/Services/Factories/ArticleParserFactory.php +++ b/backend/app/Services/Factories/ArticleParserFactory.php @@ -3,7 +3,6 @@ namespace App\Services\Factories; use App\Contracts\ArticleParserInterface; -use App\Models\Feed; use App\Services\Parsers\VrtArticleParser; use App\Services\Parsers\BelgaArticleParser; use Exception; @@ -34,25 +33,6 @@ public static function getParser(string $url): ArticleParserInterface throw new Exception("No parser found for URL: {$url}"); } - public static function getParserForFeed(Feed $feed, string $parserType = 'article'): ?ArticleParserInterface - { - if (!$feed->provider) { - return null; - } - - $providerConfig = config("feed.providers.{$feed->provider}"); - if (!$providerConfig || !isset($providerConfig['parsers'][$parserType])) { - return null; - } - - $parserClass = $providerConfig['parsers'][$parserType]; - if (!class_exists($parserClass)) { - return null; - } - - return new $parserClass(); - } - /** * @return array */ diff --git a/app/Services/Factories/HomepageParserFactory.php b/backend/app/Services/Factories/HomepageParserFactory.php similarity index 71% rename from app/Services/Factories/HomepageParserFactory.php rename to backend/app/Services/Factories/HomepageParserFactory.php index 7215961..0e5e90b 100644 --- a/app/Services/Factories/HomepageParserFactory.php +++ b/backend/app/Services/Factories/HomepageParserFactory.php @@ -36,20 +36,10 @@ public static function getParser(string $url): HomepageParserInterface public static function getParserForFeed(Feed $feed): ?HomepageParserInterface { - if (!$feed->provider) { + try { + return self::getParser($feed->url); + } catch (Exception) { return null; } - - $providerConfig = config("feed.providers.{$feed->provider}"); - if (!$providerConfig || !isset($providerConfig['parsers']['homepage'])) { - return null; - } - - $parserClass = $providerConfig['parsers']['homepage']; - if (!class_exists($parserClass)) { - return null; - } - - return new $parserClass(); } } diff --git a/app/Services/Http/HttpFetcher.php b/backend/app/Services/Http/HttpFetcher.php similarity index 100% rename from app/Services/Http/HttpFetcher.php rename to backend/app/Services/Http/HttpFetcher.php diff --git a/app/Services/Log/LogSaver.php b/backend/app/Services/Log/LogSaver.php similarity index 54% rename from app/Services/Log/LogSaver.php rename to backend/app/Services/Log/LogSaver.php index 5a72fb4..2592f82 100644 --- a/app/Services/Log/LogSaver.php +++ b/backend/app/Services/Log/LogSaver.php @@ -11,39 +11,39 @@ class LogSaver /** * @param array $context */ - public function info(string $message, ?PlatformChannel $channel = null, array $context = []): void + public static function info(string $message, ?PlatformChannel $channel = null, array $context = []): void { - $this->log(LogLevelEnum::INFO, $message, $channel, $context); + self::log(LogLevelEnum::INFO, $message, $channel, $context); } /** * @param array $context */ - public function error(string $message, ?PlatformChannel $channel = null, array $context = []): void + public static function error(string $message, ?PlatformChannel $channel = null, array $context = []): void { - $this->log(LogLevelEnum::ERROR, $message, $channel, $context); + self::log(LogLevelEnum::ERROR, $message, $channel, $context); } /** * @param array $context */ - public function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void + public static function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void { - $this->log(LogLevelEnum::WARNING, $message, $channel, $context); + self::log(LogLevelEnum::WARNING, $message, $channel, $context); } /** * @param array $context */ - public function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void + public static function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void { - $this->log(LogLevelEnum::DEBUG, $message, $channel, $context); + self::log(LogLevelEnum::DEBUG, $message, $channel, $context); } /** * @param array $context */ - private function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void + private static function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void { $logContext = $context; diff --git a/backend/app/Services/OnboardingRedirectService.php b/backend/app/Services/OnboardingRedirectService.php new file mode 100644 index 0000000..6382fbc --- /dev/null +++ b/backend/app/Services/OnboardingRedirectService.php @@ -0,0 +1,20 @@ +input('redirect_to'); + + if ($redirectTo) { + return redirect($redirectTo)->with('success', $successMessage); + } + + return redirect()->route($defaultRoute)->with('success', $successMessage); + } +} \ No newline at end of file diff --git a/app/Services/Parsers/BelgaArticlePageParser.php b/backend/app/Services/Parsers/BelgaArticlePageParser.php similarity index 74% rename from app/Services/Parsers/BelgaArticlePageParser.php rename to backend/app/Services/Parsers/BelgaArticlePageParser.php index b438d32..0a2d2dd 100644 --- a/app/Services/Parsers/BelgaArticlePageParser.php +++ b/backend/app/Services/Parsers/BelgaArticlePageParser.php @@ -55,41 +55,15 @@ public static function extractFullArticle(string $html): ?string $cleanHtml = preg_replace('/)<[^<]*)*<\/script>/mi', '', $html); $cleanHtml = preg_replace('/)<[^<]*)*<\/style>/mi', '', $cleanHtml); - // Look for Belga-specific paragraph class - if (preg_match_all('/]*class="[^"]*styles_paragraph__[^"]*"[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches)) { - $paragraphs = array_map(function($paragraph) { - return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); - }, $matches[1]); - - // Filter out empty paragraphs and join with double newlines - $fullText = implode("\n\n", array_filter($paragraphs, function($p) { - return trim($p) !== ''; - })); - - return $fullText ?: null; - } - - // Fallback: Try to extract from prezly-slate-document section + // Try to extract content from Belga-specific document section if (preg_match('/]*class="[^"]*prezly-slate-document[^"]*"[^>]*>(.*?)<\/section>/is', $cleanHtml, $sectionMatches)) { $sectionHtml = $sectionMatches[1]; preg_match_all('/]*>(.*?)<\/p>/is', $sectionHtml, $matches); - - if (!empty($matches[1])) { - $paragraphs = array_map(function($paragraph) { - return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); - }, $matches[1]); - - // Filter out empty paragraphs and join with double newlines - $fullText = implode("\n\n", array_filter($paragraphs, function($p) { - return trim($p) !== ''; - })); - - return $fullText ?: null; - } + } else { + // Fallback: Extract all paragraph content + preg_match_all('/]*>(.*?)<\/p>/is', $cleanHtml, $matches); } - // Final fallback: Extract all paragraph content - preg_match_all('/]*>(.*?)<\/p>/is', $cleanHtml, $matches); if (!empty($matches[1])) { $paragraphs = array_map(function($paragraph) { return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); diff --git a/app/Services/Parsers/BelgaArticleParser.php b/backend/app/Services/Parsers/BelgaArticleParser.php similarity index 100% rename from app/Services/Parsers/BelgaArticleParser.php rename to backend/app/Services/Parsers/BelgaArticleParser.php diff --git a/backend/app/Services/Parsers/BelgaHomepageParser.php b/backend/app/Services/Parsers/BelgaHomepageParser.php new file mode 100644 index 0000000..3f3c5b5 --- /dev/null +++ b/backend/app/Services/Parsers/BelgaHomepageParser.php @@ -0,0 +1,20 @@ + + */ + public static function extractArticleUrls(string $html): array + { + preg_match_all('/href="(https:\/\/www\.belganewsagency\.eu\/[a-z0-9-]+)"/', $html, $matches); + + $urls = collect($matches[1]) + ->unique() + ->toArray(); + + return $urls; + } +} \ No newline at end of file diff --git a/app/Services/Parsers/BelgaHomepageParserAdapter.php b/backend/app/Services/Parsers/BelgaHomepageParserAdapter.php similarity index 100% rename from app/Services/Parsers/BelgaHomepageParserAdapter.php rename to backend/app/Services/Parsers/BelgaHomepageParserAdapter.php diff --git a/app/Services/Parsers/VrtArticlePageParser.php b/backend/app/Services/Parsers/VrtArticlePageParser.php similarity index 100% rename from app/Services/Parsers/VrtArticlePageParser.php rename to backend/app/Services/Parsers/VrtArticlePageParser.php diff --git a/app/Services/Parsers/VrtArticleParser.php b/backend/app/Services/Parsers/VrtArticleParser.php similarity index 100% rename from app/Services/Parsers/VrtArticleParser.php rename to backend/app/Services/Parsers/VrtArticleParser.php diff --git a/app/Services/Parsers/VrtHomepageParser.php b/backend/app/Services/Parsers/VrtHomepageParser.php similarity index 100% rename from app/Services/Parsers/VrtHomepageParser.php rename to backend/app/Services/Parsers/VrtHomepageParser.php diff --git a/app/Services/Parsers/VrtHomepageParserAdapter.php b/backend/app/Services/Parsers/VrtHomepageParserAdapter.php similarity index 100% rename from app/Services/Parsers/VrtHomepageParserAdapter.php rename to backend/app/Services/Parsers/VrtHomepageParserAdapter.php diff --git a/backend/app/Services/Publishing/ArticlePublishingService.php b/backend/app/Services/Publishing/ArticlePublishingService.php new file mode 100644 index 0000000..f46955d --- /dev/null +++ b/backend/app/Services/Publishing/ArticlePublishingService.php @@ -0,0 +1,85 @@ + $extractedData + * @return EloquentCollection + * @throws PublishException + */ + public function publishToRoutedChannels(Article $article, array $extractedData): EloquentCollection + { + if (! $article->is_valid) { + throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE')); + } + + $feed = $article->feed; + + /** @var EloquentCollection $activeChannels */ + $activeChannels = $feed->activeChannels()->with(['platformInstance', 'activePlatformAccounts'])->get(); + + return $activeChannels->map(function (PlatformChannel $channel) use ($article, $extractedData) { + $account = $channel->activePlatformAccounts()->first(); + + if (! $account) { + LogSaver::warning('No active account for channel', $channel, [ + 'article_id' => $article->id + ]); + + return null; + } + + return $this->publishToChannel($article, $extractedData, $channel, $account); + }) + ->filter(); + } + + /** + * @param array $extractedData + */ + private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication + { + try { + $publisher = new LemmyPublisher($account); + $postData = $publisher->publishToChannel($article, $extractedData, $channel); + + $publication = ArticlePublication::create([ + 'article_id' => $article->id, + 'post_id' => $postData['post_view']['post']['id'], + 'platform_channel_id' => $channel->id, + 'published_by' => $account->username, + 'published_at' => now(), + 'platform' => $channel->platformInstance->platform->value, + 'publication_data' => $postData, + ]); + + LogSaver::info('Published to channel via routing', $channel, [ + 'article_id' => $article->id, + 'priority' => $channel->pivot->priority ?? null + ]); + + return $publication; + } catch (Exception $e) { + LogSaver::warning('Failed to publish to channel', $channel, [ + 'article_id' => $article->id, + 'error' => $e->getMessage() + ]); + + return null; + } + } +} diff --git a/app/Services/RoutingValidationService.php b/backend/app/Services/RoutingValidationService.php similarity index 100% rename from app/Services/RoutingValidationService.php rename to backend/app/Services/RoutingValidationService.php diff --git a/app/Services/SystemStatusService.php b/backend/app/Services/SystemStatusService.php similarity index 100% rename from app/Services/SystemStatusService.php rename to backend/app/Services/SystemStatusService.php diff --git a/artisan b/backend/artisan similarity index 100% rename from artisan rename to backend/artisan diff --git a/bootstrap/app.php b/backend/bootstrap/app.php similarity index 74% rename from bootstrap/app.php rename to backend/bootstrap/app.php index 5db8c44..b08c378 100644 --- a/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -1,10 +1,11 @@ withRouting( @@ -14,9 +15,12 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware) { - $middleware->alias([ - 'onboarding.complete' => EnsureOnboardingComplete::class, - 'onboarding.incomplete' => RedirectIfOnboardingComplete::class, + $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); + + $middleware->web(append: [ + HandleAppearance::class, + HandleInertiaRequests::class, + AddLinkHeadersForPreloadedAssets::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/bootstrap/cache/.gitignore b/backend/bootstrap/cache/.gitignore similarity index 100% rename from bootstrap/cache/.gitignore rename to backend/bootstrap/cache/.gitignore diff --git a/bootstrap/providers.php b/backend/bootstrap/providers.php similarity index 100% rename from bootstrap/providers.php rename to backend/bootstrap/providers.php diff --git a/composer.json b/backend/composer.json similarity index 97% rename from composer.json rename to backend/composer.json index ad49d34..efea9e1 100644 --- a/composer.json +++ b/backend/composer.json @@ -16,13 +16,11 @@ "laravel/horizon": "^5.29", "laravel/sanctum": "^4.2", "laravel/tinker": "^2.10.1", - "livewire/livewire": "^4.0", "tightenco/ziggy": "^2.4" }, "require-dev": { "fakerphp/faker": "^1.23", "larastan/larastan": "^3.5", - "laravel/breeze": "^2.3", "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", "laravel/sail": "^1.43", diff --git a/backend/composer.lock b/backend/composer.lock new file mode 100644 index 0000000..4bc29c2 --- /dev/null +++ b/backend/composer.lock @@ -0,0 +1,8773 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "ad00e6f00a75471bbdb8995efb3e46e9", + "packages": [ + { + "name": "blade-ui-kit/blade-heroicons", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/driesvints/blade-heroicons.git", + "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driesvints/blade-heroicons/zipball/4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", + "reference": "4553b2a1f6c76f0ac7f3bc0de4c0cfa06a097d19", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-icons": "^1.6", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.0|^10.5|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BladeUI\\Heroicons\\BladeHeroiconsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "BladeUI\\Heroicons\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dries Vints", + "homepage": "https://driesvints.com" + } + ], + "description": "A package to easily make use of Heroicons in your Laravel Blade views.", + "homepage": "https://github.com/blade-ui-kit/blade-heroicons", + "keywords": [ + "Heroicons", + "blade", + "laravel" + ], + "support": { + "issues": "https://github.com/driesvints/blade-heroicons/issues", + "source": "https://github.com/driesvints/blade-heroicons/tree/2.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/driesvints", + "type": "github" + }, + { + "url": "https://www.paypal.com/paypalme/driesvints", + "type": "paypal" + } + ], + "time": "2025-02-13T20:53:33+00:00" + }, + { + "name": "blade-ui-kit/blade-icons", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/driesvints/blade-icons.git", + "reference": "7b743f27476acb2ed04cb518213d78abe096e814" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/7b743f27476acb2ed04cb518213d78abe096e814", + "reference": "7b743f27476acb2ed04cb518213d78abe096e814", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/filesystem": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/view": "^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.4|^8.0", + "symfony/console": "^5.3|^6.0|^7.0", + "symfony/finder": "^5.3|^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.5.1", + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.0|^10.5|^11.0" + }, + "bin": [ + "bin/blade-icons-generate" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BladeUI\\Icons\\BladeIconsServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "BladeUI\\Icons\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dries Vints", + "homepage": "https://driesvints.com" + } + ], + "description": "A package to easily make use of icons in your Laravel Blade views.", + "homepage": "https://github.com/blade-ui-kit/blade-icons", + "keywords": [ + "blade", + "icons", + "laravel", + "svg" + ], + "support": { + "issues": "https://github.com/blade-ui-kit/blade-icons/issues", + "source": "https://github.com/blade-ui-kit/blade-icons" + }, + "funding": [ + { + "url": "https://github.com/sponsors/driesvints", + "type": "github" + }, + { + "url": "https://www.paypal.com/paypalme/driesvints", + "type": "paypal" + } + ], + "time": "2025-02-13T20:35:06+00:00" + }, + { + "name": "brick/math", + "version": "0.13.1", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.13.1" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-03-29T13:50:30+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.0.10", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^11.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^8.5 || ^9.5", + "vimeo/psalm": "^4.25 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.0.10" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2024-02-18T20:23:39+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "8c784d071debd117328803d86b2097615b457500" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2024-10-09T13:47:03+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "symfony/http-foundation": "^4.4|^5.4|^6|^7" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2023-10-12T05:21:21+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:45:45+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.9.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:37:11+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:27:01+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-03-27T12:30:47+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", + "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-02-03T10:55:03+00:00" + }, + { + "name": "inertiajs/inertia-laravel", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/inertiajs/inertia-laravel.git", + "reference": "bab0c0c992aa36e63d800903288d490d6b774d97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/bab0c0c992aa36e63d800903288d490d6b774d97", + "reference": "bab0c0c992aa36e63d800903288d490d6b774d97", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^10.0|^11.0|^12.0", + "php": "^8.1.0", + "symfony/console": "^6.2|^7.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.2", + "laravel/pint": "^1.16", + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^8.0|^9.2|^10.0", + "phpunit/phpunit": "^10.4|^11.5", + "roave/security-advisories": "dev-master" + }, + "suggest": { + "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Inertia\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "./helpers.php" + ], + "psr-4": { + "Inertia\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Reinink", + "email": "jonathan@reinink.ca", + "homepage": "https://reinink.ca" + } + ], + "description": "The Laravel adapter for Inertia.js.", + "keywords": [ + "inertia", + "laravel" + ], + "support": { + "issues": "https://github.com/inertiajs/inertia-laravel/issues", + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.4" + }, + "time": "2025-07-15T08:08:04+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.21.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.7", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.31", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^10.0.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-07-22T15:41:55+00:00" + }, + { + "name": "laravel/horizon", + "version": "v5.33.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/horizon.git", + "reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/horizon/zipball/50057bca1f1dcc9fbd5ff6d65143833babd784b3", + "reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcntl": "*", + "ext-posix": "*", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.17|^3.0", + "php": "^8.0", + "ramsey/uuid": "^4.0", + "symfony/console": "^6.0|^7.0", + "symfony/error-handler": "^6.0|^7.0", + "symfony/polyfill-php83": "^1.28", + "symfony/process": "^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.0|^10.4|^11.5", + "predis/predis": "^1.1|^2.0" + }, + "suggest": { + "ext-redis": "Required to use the Redis PHP driver.", + "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0)." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Horizon": "Laravel\\Horizon\\Horizon" + }, + "providers": [ + "Laravel\\Horizon\\HorizonServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Horizon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Dashboard and code-driven configuration for Laravel queues.", + "keywords": [ + "laravel", + "queue" + ], + "support": { + "issues": "https://github.com/laravel/horizon/issues", + "source": "https://github.com/laravel/horizon/tree/v5.33.1" + }, + "time": "2025-06-16T13:48:30+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/86a8b692e8661d0fb308cec64f3d176821323077", + "reference": "86a8b692e8661d0fb308cec64f3d176821323077", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-mockery": "^1.1" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.6" + }, + "time": "2025-07-07T14:17:42+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.2.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2025-07-09T19:45:24+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2025-03-19T13:51:03+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.10.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.10.1" + }, + "time": "2025-01-27T14:24:01+00:00" + }, + { + "name": "league/commonmark", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2025-07-20T12:47:49+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.30.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" + }, + "time": "2025-06-25T13:29:59+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.30.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + }, + "time": "2025-05-21T10:34:19+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "81fb5145d2644324614cc532b28efd0215bda430" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.5", + "php": "^8.1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:40:02+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.5.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-factory": "^1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common interfaces and classes for URI representation and interaction", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:18:47+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.10.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.75.0", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^10.5.46", + "squizlabs/php_codesniffer": "^3.13.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2025-06-21T15:19:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.2", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.4" + }, + "require-dev": { + "nette/tester": "^2.5.2", + "phpstan/phpstan-nette": "^1.0", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.2" + }, + "time": "2024-10-06T23:10:23+00:00" + }, + { + "name": "nette/utils", + "version": "v4.0.7", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "shasum": "" + }, + "require": { + "php": "8.0 - 8.4" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "dev-master", + "nette/tester": "^2.5", + "phpstan/phpstan": "^1.0", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.0.7" + }, + "time": "2025-06-03T04:55:08+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.6.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" + }, + "time": "2025-07-27T20:03:57+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.2.6" + }, + "require-dev": { + "illuminate/console": "^11.44.7", + "laravel/pint": "^1.22.0", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.2", + "phpstan/phpstan": "^1.12.25", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.2.6", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Its like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2025-05-08T08:14:37+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.3", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", + "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:41:07+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.9", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "1b801844becfe648985372cb4b12ad6840245ace" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", + "reference": "1b801844becfe648985372cb4b12ad6840245ace", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2" + }, + "suggest": { + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-pdo-sqlite": "The doc command requires SQLite to work.", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "http://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" + }, + "time": "2025-06-23T02:35:06+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.0", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.0" + }, + "time": "2025-06-25T14:20:11+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/console", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-30T17:13:41+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^6.4|^7.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-07T08:17:57+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-22T09:11:45+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:47:49+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^7.3", + "symfony/http-foundation": "^7.3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^7.1", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^7.1", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-31T10:45:04+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:36:08+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-17T09:11:12+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:36:08+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-25T09:37:31+00:00" + }, + { + "name": "symfony/string", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:47:49+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-30T17:31:46+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-27T08:32:26+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "53205bea27450dc5c65377518b3275e126d45e75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", + "reference": "53205bea27450dc5c65377518b3275e126d45e75", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-29T20:02:46+00:00" + }, + { + "name": "tightenco/ziggy", + "version": "v2.5.3", + "source": { + "type": "git", + "url": "https://github.com/tighten/ziggy.git", + "reference": "0b3b521d2c55fbdb04b6721532f7f5f49d32f52b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tighten/ziggy/zipball/0b3b521d2c55fbdb04b6721532f7f5f49d32f52b", + "reference": "0b3b521d2c55fbdb04b6721532f7f5f49d32f52b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": ">=9.0", + "php": ">=8.1" + }, + "require-dev": { + "laravel/folio": "^1.1", + "orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0", + "pestphp/pest": "^2.26|^3.0", + "pestphp/pest-plugin-laravel": "^2.4|^3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Tighten\\Ziggy\\ZiggyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Tighten\\Ziggy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Coulbourne", + "email": "daniel@tighten.co" + }, + { + "name": "Jake Bathman", + "email": "jake@tighten.co" + }, + { + "name": "Jacob Baker-Kretzmar", + "email": "jacob@tighten.co" + } + ], + "description": "Use your Laravel named routes in JavaScript.", + "homepage": "https://github.com/tighten/ziggy", + "keywords": [ + "Ziggy", + "javascript", + "laravel", + "routes" + ], + "support": { + "issues": "https://github.com/tighten/ziggy/issues", + "source": "https://github.com/tighten/ziggy/tree/v2.5.3" + }, + "time": "2025-05-17T18:15:19+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + }, + "time": "2024-12-21T16:25:41+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.3", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3", + "symfony/polyfill-ctype": "^1.24", + "symfony/polyfill-mbstring": "^1.24", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-04-30T23:37:27+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.3", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "59a123a3d459c5a23055802237cb317f609867e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", + "reference": "59a123a3d459c5a23055802237cb317f609867e5", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.3" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-06-16T00:02:10+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "iamcal/sql-parser", + "version": "v0.6", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + }, + "time": "2025-03-17T16:59:46+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "6431d010dd383a9279eb8874a76ddb571738564a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/6431d010dd383a9279eb8874a76ddb571738564a", + "reference": "6431d010dd383a9279eb8874a76ddb571738564a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.6.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.11" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2025-07-11T06:52:52+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0", + "illuminate/contracts": "^10.24|^11.0|^12.0", + "illuminate/log": "^10.24|^11.0|^12.0", + "illuminate/process": "^10.24|^11.0|^12.0", + "illuminate/support": "^10.24|^11.0|^12.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.0|^10.0", + "pestphp/pest": "^2.20|^3.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2025-06-05T13:55:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.82.2", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.1", + "pestphp/pest": "^2.36.0" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2025-07-10T18:09:32+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.44.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", + "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0", + "symfony/yaml": "^6.0|^7.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.10" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2025-07-04T16:17:06+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.8.2", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.1", + "nunomaduro/termwind": "^2.3.1", + "php": "^8.2.0", + "symfony/console": "^7.3.0" + }, + "conflict": { + "laravel/framework": "<11.44.2 || >=13.0.0", + "phpunit/phpunit": "<11.5.15 || >=13.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.3", + "larastan/larastan": "^3.4.2", + "laravel/framework": "^11.44.2 || ^12.18", + "laravel/pint": "^1.22.1", + "laravel/sail": "^1.43.1", + "laravel/sanctum": "^4.1.1", + "laravel/tinker": "^2.10.1", + "orchestra/testbench-core": "^9.12.0 || ^10.4", + "pestphp/pest": "^3.8.2", + "sebastian/environment": "^7.2.1 || ^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2025-06-25T02:12:12+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.21", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1ccf445757458c06a04eb3f803603cb118fe5fa6", + "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-07-28T19:35:08+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.4.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-06-18T08:56:18+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.28", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "93f30aa3889e785ac63493d4976df0ae9fdecb60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/93f30aa3889e785ac63493d4976df0ae9fdecb60", + "reference": "93f30aa3889e785ac63493d4976df0ae9fdecb60", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.3", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.1", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.0", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.2", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.28" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-07-31T07:10:28+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-07T06:57:01+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-12-05T09:17:50+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:10:34+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-18T13:35:50+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:47:49+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:36:25+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config/app.php b/backend/config/app.php similarity index 100% rename from config/app.php rename to backend/config/app.php diff --git a/config/auth.php b/backend/config/auth.php similarity index 100% rename from config/auth.php rename to backend/config/auth.php diff --git a/config/cache.php b/backend/config/cache.php similarity index 100% rename from config/cache.php rename to backend/config/cache.php diff --git a/config/database.php b/backend/config/database.php similarity index 100% rename from config/database.php rename to backend/config/database.php diff --git a/config/filesystems.php b/backend/config/filesystems.php similarity index 100% rename from config/filesystems.php rename to backend/config/filesystems.php diff --git a/config/horizon.php b/backend/config/horizon.php similarity index 99% rename from config/horizon.php rename to backend/config/horizon.php index 34761ea..cf9d5cf 100644 --- a/config/horizon.php +++ b/backend/config/horizon.php @@ -182,7 +182,7 @@ 'defaults' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['default', 'publishing', 'feed-discovery'], + 'queue' => ['default', 'lemmy-posts', 'lemmy-publish'], 'balance' => 'auto', 'autoScalingStrategy' => 'time', 'maxProcesses' => 1, diff --git a/config/logging.php b/backend/config/logging.php similarity index 100% rename from config/logging.php rename to backend/config/logging.php diff --git a/config/mail.php b/backend/config/mail.php similarity index 100% rename from config/mail.php rename to backend/config/mail.php diff --git a/config/queue.php b/backend/config/queue.php similarity index 100% rename from config/queue.php rename to backend/config/queue.php diff --git a/config/sanctum.php b/backend/config/sanctum.php similarity index 100% rename from config/sanctum.php rename to backend/config/sanctum.php diff --git a/config/services.php b/backend/config/services.php similarity index 100% rename from config/services.php rename to backend/config/services.php diff --git a/config/session.php b/backend/config/session.php similarity index 100% rename from config/session.php rename to backend/config/session.php diff --git a/backend/coverage.xml b/backend/coverage.xml new file mode 100644 index 0000000..265e200 --- /dev/null +++ b/backend/coverage.xml @@ -0,0 +1,2519 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/database/.gitignore b/backend/database/.gitignore similarity index 100% rename from database/.gitignore rename to backend/database/.gitignore diff --git a/database/factories/ArticleFactory.php b/backend/database/factories/ArticleFactory.php similarity index 61% rename from database/factories/ArticleFactory.php rename to backend/database/factories/ArticleFactory.php index 40e14ee..dbe1074 100644 --- a/database/factories/ArticleFactory.php +++ b/backend/database/factories/ArticleFactory.php @@ -21,11 +21,9 @@ public function definition(): array 'url' => $this->faker->url(), 'title' => $this->faker->sentence(), 'description' => $this->faker->paragraph(), - 'content' => $this->faker->paragraphs(3, true), - 'image_url' => $this->faker->optional()->imageUrl(), - 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), - 'author' => $this->faker->optional()->name(), - 'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']), + 'is_valid' => null, + 'is_duplicate' => false, + 'validated_at' => null, ]; } } diff --git a/database/factories/ArticlePublicationFactory.php b/backend/database/factories/ArticlePublicationFactory.php similarity index 92% rename from database/factories/ArticlePublicationFactory.php rename to backend/database/factories/ArticlePublicationFactory.php index ed59ac4..2fdd4d5 100644 --- a/database/factories/ArticlePublicationFactory.php +++ b/backend/database/factories/ArticlePublicationFactory.php @@ -17,10 +17,8 @@ public function definition(): array 'article_id' => Article::factory(), 'platform_channel_id' => PlatformChannel::factory(), 'post_id' => $this->faker->uuid(), - 'platform' => 'lemmy', 'published_at' => $this->faker->dateTimeBetween('-1 month', 'now'), 'published_by' => $this->faker->userName(), - 'publication_data' => null, 'created_at' => $this->faker->dateTimeBetween('-1 month', 'now'), 'updated_at' => now(), ]; diff --git a/database/factories/FeedFactory.php b/backend/database/factories/FeedFactory.php similarity index 64% rename from database/factories/FeedFactory.php rename to backend/database/factories/FeedFactory.php index d07c3f0..51b212a 100644 --- a/database/factories/FeedFactory.php +++ b/backend/database/factories/FeedFactory.php @@ -19,8 +19,7 @@ public function definition(): array 'name' => $this->faker->words(3, true), 'url' => $this->faker->url(), 'type' => $this->faker->randomElement(['website', 'rss']), - 'provider' => $this->faker->randomElement(['vrt', 'belga']), - 'language_id' => null, + 'language_id' => Language::factory(), 'description' => $this->faker->optional()->sentence(), 'settings' => [], 'is_active' => true, @@ -55,27 +54,4 @@ public function recentlyFetched(): static 'last_fetched_at' => now()->subHour(), ]); } - - public function language(Language $language): static - { - return $this->state(fn (array $attributes) => [ - 'language_id' => $language->id, - ]); - } - - public function vrt(): static - { - return $this->state(fn (array $attributes) => [ - 'provider' => 'vrt', - 'url' => 'https://www.vrt.be/vrtnws/en/', - ]); - } - - public function belga(): static - { - return $this->state(fn (array $attributes) => [ - 'provider' => 'belga', - 'url' => 'https://www.belganewsagency.eu/', - ]); - } } \ No newline at end of file diff --git a/database/factories/KeywordFactory.php b/backend/database/factories/KeywordFactory.php similarity index 86% rename from database/factories/KeywordFactory.php rename to backend/database/factories/KeywordFactory.php index 57e6f0b..0203c97 100644 --- a/database/factories/KeywordFactory.php +++ b/backend/database/factories/KeywordFactory.php @@ -12,9 +12,9 @@ class KeywordFactory extends Factory public function definition(): array { return [ - 'feed_id' => \App\Models\Feed::factory(), - 'platform_channel_id' => \App\Models\PlatformChannel::factory(), - 'keyword' => $this->faker->word(), + 'feed_id' => null, + 'platform_channel_id' => null, + 'keyword' => 'test keyword', 'is_active' => $this->faker->boolean(70), // 70% chance of being active 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), 'updated_at' => now(), diff --git a/database/factories/LanguageFactory.php b/backend/database/factories/LanguageFactory.php similarity index 100% rename from database/factories/LanguageFactory.php rename to backend/database/factories/LanguageFactory.php diff --git a/database/factories/LogFactory.php b/backend/database/factories/LogFactory.php similarity index 100% rename from database/factories/LogFactory.php rename to backend/database/factories/LogFactory.php diff --git a/database/factories/PlatformAccountFactory.php b/backend/database/factories/PlatformAccountFactory.php similarity index 100% rename from database/factories/PlatformAccountFactory.php rename to backend/database/factories/PlatformAccountFactory.php diff --git a/database/factories/PlatformChannelFactory.php b/backend/database/factories/PlatformChannelFactory.php similarity index 100% rename from database/factories/PlatformChannelFactory.php rename to backend/database/factories/PlatformChannelFactory.php diff --git a/database/factories/PlatformInstanceFactory.php b/backend/database/factories/PlatformInstanceFactory.php similarity index 100% rename from database/factories/PlatformInstanceFactory.php rename to backend/database/factories/PlatformInstanceFactory.php diff --git a/database/factories/RouteFactory.php b/backend/database/factories/RouteFactory.php similarity index 93% rename from database/factories/RouteFactory.php rename to backend/database/factories/RouteFactory.php index 53177ac..93036a4 100644 --- a/database/factories/RouteFactory.php +++ b/backend/database/factories/RouteFactory.php @@ -17,7 +17,6 @@ public function definition(): array 'feed_id' => Feed::factory(), 'platform_channel_id' => PlatformChannel::factory(), 'is_active' => $this->faker->boolean(80), // 80% chance of being active - 'priority' => $this->faker->numberBetween(0, 100), 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), 'updated_at' => now(), ]; diff --git a/database/factories/SettingFactory.php b/backend/database/factories/SettingFactory.php similarity index 100% rename from database/factories/SettingFactory.php rename to backend/database/factories/SettingFactory.php diff --git a/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php similarity index 100% rename from database/factories/UserFactory.php rename to backend/database/factories/UserFactory.php diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php similarity index 100% rename from database/migrations/0001_01_01_000000_create_users_table.php rename to backend/database/migrations/0001_01_01_000000_create_users_table.php diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/backend/database/migrations/0001_01_01_000001_create_cache_table.php similarity index 100% rename from database/migrations/0001_01_01_000001_create_cache_table.php rename to backend/database/migrations/0001_01_01_000001_create_cache_table.php diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php similarity index 100% rename from database/migrations/0001_01_01_000002_create_jobs_table.php rename to backend/database/migrations/0001_01_01_000002_create_jobs_table.php diff --git a/backend/database/migrations/2025_06_29_072202_create_articles_table.php b/backend/database/migrations/2025_06_29_072202_create_articles_table.php new file mode 100644 index 0000000..a196d53 --- /dev/null +++ b/backend/database/migrations/2025_06_29_072202_create_articles_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('url'); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_valid')->nullable(); + $table->boolean('is_duplicate')->default(false); + $table->timestamp('validated_at')->nullable(); + $table->timestamps(); + + $table->index('url'); + $table->index('is_valid'); + $table->index('validated_at'); + $table->index('created_at'); + $table->index(['is_valid', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('articles'); + } +}; diff --git a/backend/database/migrations/2025_06_29_154705_create_logs_table.php b/backend/database/migrations/2025_06_29_154705_create_logs_table.php new file mode 100644 index 0000000..985ddd0 --- /dev/null +++ b/backend/database/migrations/2025_06_29_154705_create_logs_table.php @@ -0,0 +1,25 @@ +id(); + $table->enum('level', LogLevelEnum::toArray()); + $table->string('message'); + $table->json('context')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('logs'); + } +}; diff --git a/backend/database/migrations/2025_06_29_181847_create_article_publications_table.php b/backend/database/migrations/2025_06_29_181847_create_article_publications_table.php new file mode 100644 index 0000000..d0b77cd --- /dev/null +++ b/backend/database/migrations/2025_06_29_181847_create_article_publications_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('article_id')->constrained()->onDelete('cascade'); + $table->string('post_id'); + $table->unsignedBigInteger('platform_channel_id'); + $table->string('platform')->default('lemmy'); + $table->json('publication_data')->nullable(); + $table->timestamp('published_at'); + $table->string('published_by'); + $table->timestamps(); + + $table->unique(['article_id', 'platform', 'platform_channel_id'], 'article_pub_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('article_publications'); + } +}; diff --git a/backend/database/migrations/2025_06_30_163438_create_platform_channel_posts_table.php b/backend/database/migrations/2025_06_30_163438_create_platform_channel_posts_table.php new file mode 100644 index 0000000..d8c1914 --- /dev/null +++ b/backend/database/migrations/2025_06_30_163438_create_platform_channel_posts_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('platform'); + $table->string('channel_id'); + $table->string('channel_name')->nullable(); + $table->string('post_id'); + $table->longText('url')->nullable(); + $table->string('title')->nullable(); + $table->timestamp('posted_at'); + $table->timestamps(); + + $table->index(['platform', 'channel_id']); + $table->index(['platform', 'channel_id', 'posted_at']); + // Will add URL index with prefix after table creation + $table->unique(['platform', 'channel_id', 'post_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_channel_posts'); + } +}; diff --git a/backend/database/migrations/2025_07_04_230000_create_platform_accounts_table.php b/backend/database/migrations/2025_07_04_230000_create_platform_accounts_table.php new file mode 100644 index 0000000..51c7faa --- /dev/null +++ b/backend/database/migrations/2025_07_04_230000_create_platform_accounts_table.php @@ -0,0 +1,31 @@ +id(); + $table->enum('platform', ['lemmy']); + $table->string('instance_url'); + $table->string('username'); + $table->string('password'); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(false); + $table->timestamp('last_tested_at')->nullable(); + $table->string('status')->default('untested'); + $table->timestamps(); + + $table->unique(['username', 'platform', 'is_active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_accounts'); + } +}; \ No newline at end of file diff --git a/backend/database/migrations/2025_07_04_233000_create_platform_instances_table.php b/backend/database/migrations/2025_07_04_233000_create_platform_instances_table.php new file mode 100644 index 0000000..23521db --- /dev/null +++ b/backend/database/migrations/2025_07_04_233000_create_platform_instances_table.php @@ -0,0 +1,28 @@ +id(); + $table->enum('platform', ['lemmy']); + $table->string('url'); // lemmy.world, beehaw.org + $table->string('name'); // "Lemmy World", "Beehaw" + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['platform', 'url']); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_instances'); + } +}; \ No newline at end of file diff --git a/backend/database/migrations/2025_07_04_233100_create_platform_channels_table.php b/backend/database/migrations/2025_07_04_233100_create_platform_channels_table.php new file mode 100644 index 0000000..51b1c0a --- /dev/null +++ b/backend/database/migrations/2025_07_04_233100_create_platform_channels_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('platform_instance_id')->constrained()->onDelete('cascade'); + $table->string('name'); // "technology" + $table->string('display_name'); // "Technology" + $table->string('channel_id'); // API ID from platform + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['platform_instance_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_channels'); + } +}; \ No newline at end of file diff --git a/backend/database/migrations/2025_07_04_233200_create_platform_account_channels_table.php b/backend/database/migrations/2025_07_04_233200_create_platform_account_channels_table.php new file mode 100644 index 0000000..87bb22e --- /dev/null +++ b/backend/database/migrations/2025_07_04_233200_create_platform_account_channels_table.php @@ -0,0 +1,26 @@ +foreignId('platform_account_id')->constrained()->onDelete('cascade'); + $table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); + $table->boolean('is_active')->default(false); + $table->integer('priority')->default(0); // for ordering + $table->timestamps(); + + $table->primary(['platform_account_id', 'platform_channel_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_account_channels'); + } +}; \ No newline at end of file diff --git a/backend/database/migrations/2025_07_05_003216_create_feeds_table.php b/backend/database/migrations/2025_07_05_003216_create_feeds_table.php new file mode 100644 index 0000000..4131468 --- /dev/null +++ b/backend/database/migrations/2025_07_05_003216_create_feeds_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); // "VRT News", "Belga News Agency" + $table->string('url'); // "https://vrt.be" or "https://feeds.example.com/rss.xml" + $table->enum('type', ['website', 'rss']); // Feed type + $table->string('language', 5)->default('en'); // Language code (en, nl, etc.) + $table->text('description')->nullable(); + $table->json('settings')->nullable(); // Custom settings per feed type + $table->boolean('is_active')->default(true); + $table->timestamp('last_fetched_at')->nullable(); + $table->timestamps(); + + $table->unique('url'); + }); + } + + public function down(): void + { + Schema::dropIfExists('feeds'); + } +}; \ No newline at end of file diff --git a/backend/database/migrations/2025_07_05_005128_create_routes_table.php b/backend/database/migrations/2025_07_05_005128_create_routes_table.php new file mode 100644 index 0000000..7610fda --- /dev/null +++ b/backend/database/migrations/2025_07_05_005128_create_routes_table.php @@ -0,0 +1,27 @@ +foreignId('feed_id')->constrained()->onDelete('cascade'); + $table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); + $table->boolean('is_active')->default(true); + $table->integer('priority')->default(0); // for ordering/priority + $table->json('filters')->nullable(); // keyword filters, content filters, etc. + $table->timestamps(); + + $table->primary(['feed_id', 'platform_channel_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('routes'); + } +}; \ No newline at end of file diff --git a/backend/database/migrations/2025_07_05_070153_add_feed_id_to_articles_table.php b/backend/database/migrations/2025_07_05_070153_add_feed_id_to_articles_table.php new file mode 100644 index 0000000..9ff63bc --- /dev/null +++ b/backend/database/migrations/2025_07_05_070153_add_feed_id_to_articles_table.php @@ -0,0 +1,25 @@ +foreignId('feed_id')->nullable()->constrained()->onDelete('cascade'); + $table->index(['feed_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropIndex(['feed_id', 'created_at']); + $table->dropForeign(['feed_id']); + $table->dropColumn('feed_id'); + }); + } +}; diff --git a/backend/database/migrations/2025_07_05_124728_add_language_to_platform_channels_table.php b/backend/database/migrations/2025_07_05_124728_add_language_to_platform_channels_table.php new file mode 100644 index 0000000..7202426 --- /dev/null +++ b/backend/database/migrations/2025_07_05_124728_add_language_to_platform_channels_table.php @@ -0,0 +1,22 @@ +string('language', 2)->nullable()->after('description'); + }); + } + + public function down(): void + { + Schema::table('platform_channels', function (Blueprint $table) { + $table->dropColumn('language'); + }); + } +}; diff --git a/database/migrations/2024_01_01_000002_create_languages.php b/backend/database/migrations/2025_07_05_142122_create_languages_table.php similarity index 83% rename from database/migrations/2024_01_01_000002_create_languages.php rename to backend/database/migrations/2025_07_05_142122_create_languages_table.php index 0b55878..79e1f67 100644 --- a/database/migrations/2024_01_01_000002_create_languages.php +++ b/backend/database/migrations/2025_07_05_142122_create_languages_table.php @@ -8,10 +8,9 @@ { public function up(): void { - // Languages table Schema::create('languages', function (Blueprint $table) { $table->id(); - $table->string('short_code', 10)->unique(); // Language code (en, fr, de, en-US, zh-CN, etc.) + $table->string('short_code', 2)->unique(); // ISO 639-1 language code (en, fr, de, etc.) $table->string('name'); // English name (English, French, German, etc.) $table->string('native_name')->nullable(); // Native name (English, Français, Deutsch, etc.) $table->boolean('is_active')->default(true); @@ -23,4 +22,4 @@ public function down(): void { Schema::dropIfExists('languages'); } -}; \ No newline at end of file +}; diff --git a/backend/database/migrations/2025_07_05_142443_create_language_platform_instance_table.php b/backend/database/migrations/2025_07_05_142443_create_language_platform_instance_table.php new file mode 100644 index 0000000..bbaac64 --- /dev/null +++ b/backend/database/migrations/2025_07_05_142443_create_language_platform_instance_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('language_id')->constrained()->onDelete('cascade'); + $table->foreignId('platform_instance_id')->constrained()->onDelete('cascade'); + $table->integer('platform_language_id')->nullable(); // The platform-specific ID (e.g., Lemmy's language ID) + $table->boolean('is_default')->default(false); // Whether this is the default language for this instance + $table->timestamps(); + + $table->unique(['language_id', 'platform_instance_id'], 'lang_platform_instance_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('language_platform_instance'); + } +}; diff --git a/backend/database/migrations/2025_07_05_142644_update_feeds_table_use_language_id.php b/backend/database/migrations/2025_07_05_142644_update_feeds_table_use_language_id.php new file mode 100644 index 0000000..0654ea3 --- /dev/null +++ b/backend/database/migrations/2025_07_05_142644_update_feeds_table_use_language_id.php @@ -0,0 +1,25 @@ +dropColumn('language'); + $table->foreignId('language_id')->nullable()->constrained(); + }); + } + + public function down(): void + { + Schema::table('feeds', function (Blueprint $table) { + $table->dropForeign(['language_id']); + $table->dropColumn('language_id'); + $table->string('language', 2)->nullable(); + }); + } +}; diff --git a/backend/database/migrations/2025_07_05_142807_update_platform_channels_table_use_language_id.php b/backend/database/migrations/2025_07_05_142807_update_platform_channels_table_use_language_id.php new file mode 100644 index 0000000..fe75ca0 --- /dev/null +++ b/backend/database/migrations/2025_07_05_142807_update_platform_channels_table_use_language_id.php @@ -0,0 +1,25 @@ +dropColumn('language'); + $table->foreignId('language_id')->nullable()->constrained(); + }); + } + + public function down(): void + { + Schema::table('platform_channels', function (Blueprint $table) { + $table->dropForeign(['language_id']); + $table->dropColumn('language_id'); + $table->string('language', 2)->nullable(); + }); + } +}; diff --git a/backend/database/migrations/2025_07_05_163010_create_keywords_table.php b/backend/database/migrations/2025_07_05_163010_create_keywords_table.php new file mode 100644 index 0000000..8f9889a --- /dev/null +++ b/backend/database/migrations/2025_07_05_163010_create_keywords_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('feed_id')->constrained()->onDelete('cascade'); + $table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); + $table->string('keyword'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['feed_id', 'platform_channel_id', 'keyword']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('keywords'); + } +}; diff --git a/backend/database/migrations/2025_07_10_085210_create_settings_table.php b/backend/database/migrations/2025_07_10_085210_create_settings_table.php new file mode 100644 index 0000000..c157b1e --- /dev/null +++ b/backend/database/migrations/2025_07_10_085210_create_settings_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('key')->unique(); + $table->text('value'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/backend/database/migrations/2025_07_10_102123_add_approval_status_to_articles_table.php b/backend/database/migrations/2025_07_10_102123_add_approval_status_to_articles_table.php new file mode 100644 index 0000000..af85c0c --- /dev/null +++ b/backend/database/migrations/2025_07_10_102123_add_approval_status_to_articles_table.php @@ -0,0 +1,35 @@ +enum('approval_status', ['pending', 'approved', 'rejected']) + ->default('pending') + ->after('is_duplicate'); + $table->timestamp('approved_at')->nullable()->after('approval_status'); + $table->string('approved_by')->nullable()->after('approved_at'); + + $table->index('approval_status'); + $table->index(['is_valid', 'approval_status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropColumn(['approval_status', 'approved_at', 'approved_by']); + }); + } +}; diff --git a/database/migrations/2024_01_01_000005_create_personal_access_tokens_table.php b/backend/database/migrations/2025_08_02_131416_create_personal_access_tokens_table.php similarity index 100% rename from database/migrations/2024_01_01_000005_create_personal_access_tokens_table.php rename to backend/database/migrations/2025_08_02_131416_create_personal_access_tokens_table.php diff --git a/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php similarity index 59% rename from database/seeders/DatabaseSeeder.php rename to backend/database/seeders/DatabaseSeeder.php index d074784..264345a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -8,9 +8,6 @@ class DatabaseSeeder extends Seeder { public function run(): void { - $this->call([ - SettingsSeeder::class, - LanguageSeeder::class, - ]); + $this->call(SettingsSeeder::class); } } diff --git a/database/seeders/PlatformInstanceSeeder.php b/backend/database/seeders/PlatformInstanceSeeder.php similarity index 100% rename from database/seeders/PlatformInstanceSeeder.php rename to backend/database/seeders/PlatformInstanceSeeder.php diff --git a/database/seeders/SettingsSeeder.php b/backend/database/seeders/SettingsSeeder.php similarity index 100% rename from database/seeders/SettingsSeeder.php rename to backend/database/seeders/SettingsSeeder.php diff --git a/phpstan.neon b/backend/phpstan.neon similarity index 100% rename from phpstan.neon rename to backend/phpstan.neon diff --git a/phpunit.xml b/backend/phpunit.xml similarity index 100% rename from phpunit.xml rename to backend/phpunit.xml diff --git a/public/.htaccess b/backend/public/.htaccess similarity index 100% rename from public/.htaccess rename to backend/public/.htaccess diff --git a/public/apple-touch-icon.png b/backend/public/apple-touch-icon.png similarity index 100% rename from public/apple-touch-icon.png rename to backend/public/apple-touch-icon.png diff --git a/backend/public/build/assets/app-BR3co0lu.js b/backend/public/build/assets/app-BR3co0lu.js new file mode 100644 index 0000000..67c71c5 --- /dev/null +++ b/backend/public/build/assets/app-BR3co0lu.js @@ -0,0 +1,65 @@ +var fm=a=>{throw TypeError(a)};var Bs=(a,i,c)=>i.has(a)||fm("Cannot "+c);var q=(a,i,c)=>(Bs(a,i,"read from private field"),c?c.call(a):i.get(a)),vt=(a,i,c)=>i.has(a)?fm("Cannot add the same private member more than once"):i instanceof WeakSet?i.add(a):i.set(a,c),at=(a,i,c,s)=>(Bs(a,i,"write to private field"),s?s.call(a,c):i.set(a,c),c),It=(a,i,c)=>(Bs(a,i,"access private method"),c);var Ii=(a,i,c,s)=>({set _(f){at(a,i,f,c)},get _(){return q(a,i,s)}});function pp(a){return a&&a.__esModule&&Object.prototype.hasOwnProperty.call(a,"default")?a.default:a}var Ls={exports:{}},Eu={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var om;function gp(){if(om)return Eu;om=1;var a=Symbol.for("react.transitional.element"),i=Symbol.for("react.fragment");function c(s,f,d){var m=null;if(d!==void 0&&(m=""+d),f.key!==void 0&&(m=""+f.key),"key"in f){d={};for(var v in f)v!=="key"&&(d[v]=f[v])}else d=f;return f=d.ref,{$$typeof:a,type:s,key:m,ref:f!==void 0?f:null,props:d}}return Eu.Fragment=i,Eu.jsx=c,Eu.jsxs=c,Eu}var dm;function bp(){return dm||(dm=1,Ls.exports=gp()),Ls.exports}var vl=bp(),js={exports:{}},it={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var hm;function Sp(){if(hm)return it;hm=1;var a=Symbol.for("react.transitional.element"),i=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),f=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),m=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),b=Symbol.for("react.lazy"),O=Symbol.iterator;function _(S){return S===null||typeof S!="object"?null:(S=O&&S[O]||S["@@iterator"],typeof S=="function"?S:null)}var Y={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},x=Object.assign,z={};function C(S,Q,K){this.props=S,this.context=Q,this.refs=z,this.updater=K||Y}C.prototype.isReactComponent={},C.prototype.setState=function(S,Q){if(typeof S!="object"&&typeof S!="function"&&S!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,S,Q,"setState")},C.prototype.forceUpdate=function(S){this.updater.enqueueForceUpdate(this,S,"forceUpdate")};function L(){}L.prototype=C.prototype;function J(S,Q,K){this.props=S,this.context=Q,this.refs=z,this.updater=K||Y}var G=J.prototype=new L;G.constructor=J,x(G,C.prototype),G.isPureReactComponent=!0;var $=Array.isArray,Z={H:null,A:null,T:null,S:null,V:null},ot=Object.prototype.hasOwnProperty;function ht(S,Q,K,X,W,mt){return K=mt.ref,{$$typeof:a,type:S,key:Q,ref:K!==void 0?K:null,props:mt}}function St(S,Q){return ht(S.type,Q,void 0,void 0,void 0,S.props)}function lt(S){return typeof S=="object"&&S!==null&&S.$$typeof===a}function wt(S){var Q={"=":"=0",":":"=2"};return"$"+S.replace(/[=:]/g,function(K){return Q[K]})}var ne=/\/+/g;function Ft(S,Q){return typeof S=="object"&&S!==null&&S.key!=null?wt(""+S.key):Q.toString(36)}function Wl(){}function Pl(S){switch(S.status){case"fulfilled":return S.value;case"rejected":throw S.reason;default:switch(typeof S.status=="string"?S.then(Wl,Wl):(S.status="pending",S.then(function(Q){S.status==="pending"&&(S.status="fulfilled",S.value=Q)},function(Q){S.status==="pending"&&(S.status="rejected",S.reason=Q)})),S.status){case"fulfilled":return S.value;case"rejected":throw S.reason}}throw S}function $t(S,Q,K,X,W){var mt=typeof S;(mt==="undefined"||mt==="boolean")&&(S=null);var nt=!1;if(S===null)nt=!0;else switch(mt){case"bigint":case"string":case"number":nt=!0;break;case"object":switch(S.$$typeof){case a:case i:nt=!0;break;case b:return nt=S._init,$t(nt(S._payload),Q,K,X,W)}}if(nt)return W=W(S),nt=X===""?"."+Ft(S,0):X,$(W)?(K="",nt!=null&&(K=nt.replace(ne,"$&/")+"/"),$t(W,Q,K,"",function(Sl){return Sl})):W!=null&&(lt(W)&&(W=St(W,K+(W.key==null||S&&S.key===W.key?"":(""+W.key).replace(ne,"$&/")+"/")+nt)),Q.push(W)),1;nt=0;var me=X===""?".":X+":";if($(S))for(var _t=0;_t>>1,S=w[At];if(0>>1;Atf(X,tt))Wf(mt,X)?(w[At]=mt,w[W]=tt,At=W):(w[At]=X,w[K]=tt,At=K);else if(Wf(mt,tt))w[At]=mt,w[W]=tt,At=W;else break t}}return V}function f(w,V){var tt=w.sortIndex-V.sortIndex;return tt!==0?tt:w.id-V.id}if(a.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;a.unstable_now=function(){return d.now()}}else{var m=Date,v=m.now();a.unstable_now=function(){return m.now()-v}}var p=[],y=[],b=1,O=null,_=3,Y=!1,x=!1,z=!1,C=!1,L=typeof setTimeout=="function"?setTimeout:null,J=typeof clearTimeout=="function"?clearTimeout:null,G=typeof setImmediate<"u"?setImmediate:null;function $(w){for(var V=c(y);V!==null;){if(V.callback===null)s(y);else if(V.startTime<=w)s(y),V.sortIndex=V.expirationTime,i(p,V);else break;V=c(y)}}function Z(w){if(z=!1,$(w),!x)if(c(p)!==null)x=!0,ot||(ot=!0,Ft());else{var V=c(y);V!==null&&$t(Z,V.startTime-w)}}var ot=!1,ht=-1,St=5,lt=-1;function wt(){return C?!0:!(a.unstable_now()-ltw&&wt());){var At=O.callback;if(typeof At=="function"){O.callback=null,_=O.priorityLevel;var S=At(O.expirationTime<=w);if(w=a.unstable_now(),typeof S=="function"){O.callback=S,$(w),V=!0;break e}O===c(p)&&s(p),$(w)}else s(p);O=c(p)}if(O!==null)V=!0;else{var Q=c(y);Q!==null&&$t(Z,Q.startTime-w),V=!1}}break t}finally{O=null,_=tt,Y=!1}V=void 0}}finally{V?Ft():ot=!1}}}var Ft;if(typeof G=="function")Ft=function(){G(ne)};else if(typeof MessageChannel<"u"){var Wl=new MessageChannel,Pl=Wl.port2;Wl.port1.onmessage=ne,Ft=function(){Pl.postMessage(null)}}else Ft=function(){L(ne,0)};function $t(w,V){ht=L(function(){w(a.unstable_now())},V)}a.unstable_IdlePriority=5,a.unstable_ImmediatePriority=1,a.unstable_LowPriority=4,a.unstable_NormalPriority=3,a.unstable_Profiling=null,a.unstable_UserBlockingPriority=2,a.unstable_cancelCallback=function(w){w.callback=null},a.unstable_forceFrameRate=function(w){0>w||125At?(w.sortIndex=tt,i(y,w),c(p)===null&&w===c(y)&&(z?(J(ht),ht=-1):z=!0,$t(Z,tt-At))):(w.sortIndex=S,i(p,w),x||Y||(x=!0,ot||(ot=!0,Ft()))),w},a.unstable_shouldYield=wt,a.unstable_wrapCallback=function(w){var V=_;return function(){var tt=_;_=V;try{return w.apply(this,arguments)}finally{_=tt}}}}(Gs)),Gs}var vm;function Rp(){return vm||(vm=1,Ys.exports=Tp()),Ys.exports}var Xs={exports:{}},te={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var pm;function Ap(){if(pm)return te;pm=1;var a=uf();function i(p){var y="https://react.dev/errors/"+p;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(a)}catch(i){console.error(i)}}return a(),Xs.exports=Ap(),Xs.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var bm;function Dp(){if(bm)return Tu;bm=1;var a=Rp(),i=uf(),c=Op();function s(t){var e="https://react.dev/errors/"+t;if(1S||(t.current=At[S],At[S]=null,S--)}function X(t,e){S++,At[S]=t.current,t.current=e}var W=Q(null),mt=Q(null),nt=Q(null),me=Q(null);function _t(t,e){switch(X(nt,e),X(mt,t),X(W,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?Lh(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=Lh(e),t=jh(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}K(W),X(W,t)}function Sl(){K(W),K(mt),K(nt)}function Tr(t){t.memoizedState!==null&&X(me,t);var e=W.current,l=jh(e,t.type);e!==l&&(X(mt,t),X(W,l))}function Lu(t){mt.current===t&&(K(W),K(mt)),me.current===t&&(K(me),vu._currentValue=tt)}var Rr=Object.prototype.hasOwnProperty,Ar=a.unstable_scheduleCallback,Or=a.unstable_cancelCallback,Fy=a.unstable_shouldYield,$y=a.unstable_requestPaint,Ye=a.unstable_now,Wy=a.unstable_getCurrentPriorityLevel,pf=a.unstable_ImmediatePriority,gf=a.unstable_UserBlockingPriority,ju=a.unstable_NormalPriority,Py=a.unstable_LowPriority,bf=a.unstable_IdlePriority,Iy=a.log,t0=a.unstable_setDisableYieldValue,Aa=null,ye=null;function El(t){if(typeof Iy=="function"&&t0(t),ye&&typeof ye.setStrictMode=="function")try{ye.setStrictMode(Aa,t)}catch{}}var ve=Math.clz32?Math.clz32:n0,e0=Math.log,l0=Math.LN2;function n0(t){return t>>>=0,t===0?32:31-(e0(t)/l0|0)|0}var Qu=256,Yu=4194304;function Il(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Gu(t,e,l){var n=t.pendingLanes;if(n===0)return 0;var u=0,r=t.suspendedLanes,o=t.pingedLanes;t=t.warmLanes;var h=n&134217727;return h!==0?(n=h&~r,n!==0?u=Il(n):(o&=h,o!==0?u=Il(o):l||(l=h&~t,l!==0&&(u=Il(l))))):(h=n&~r,h!==0?u=Il(h):o!==0?u=Il(o):l||(l=n&~t,l!==0&&(u=Il(l)))),u===0?0:e!==0&&e!==u&&(e&r)===0&&(r=u&-u,l=e&-e,r>=l||r===32&&(l&4194048)!==0)?e:u}function Oa(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function a0(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Sf(){var t=Qu;return Qu<<=1,(Qu&4194048)===0&&(Qu=256),t}function Ef(){var t=Yu;return Yu<<=1,(Yu&62914560)===0&&(Yu=4194304),t}function Dr(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function Da(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function u0(t,e,l,n,u,r){var o=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var h=t.entanglements,g=t.expirationTimes,A=t.hiddenUpdates;for(l=o&~l;0)":-1u||g[n]!==A[u]){var N=` +`+g[n].replace(" at new "," at ");return t.displayName&&N.includes("")&&(N=N.replace("",t.displayName)),N}while(1<=n&&0<=u);break}}}finally{xr=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?Nn(l):""}function o0(t){switch(t.tag){case 26:case 27:case 5:return Nn(t.type);case 16:return Nn("Lazy");case 13:return Nn("Suspense");case 19:return Nn("SuspenseList");case 0:case 15:return Nr(t.type,!1);case 11:return Nr(t.type.render,!1);case 1:return Nr(t.type,!0);case 31:return Nn("Activity");default:return""}}function zf(t){try{var e="";do e+=o0(t),t=t.return;while(t);return e}catch(l){return` +Error generating stack: `+l.message+` +`+l.stack}}function Ae(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function xf(t){var e=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(e==="checkbox"||e==="radio")}function d0(t){var e=xf(t)?"checked":"value",l=Object.getOwnPropertyDescriptor(t.constructor.prototype,e),n=""+t[e];if(!t.hasOwnProperty(e)&&typeof l<"u"&&typeof l.get=="function"&&typeof l.set=="function"){var u=l.get,r=l.set;return Object.defineProperty(t,e,{configurable:!0,get:function(){return u.call(this)},set:function(o){n=""+o,r.call(this,o)}}),Object.defineProperty(t,e,{enumerable:l.enumerable}),{getValue:function(){return n},setValue:function(o){n=""+o},stopTracking:function(){t._valueTracker=null,delete t[e]}}}}function Zu(t){t._valueTracker||(t._valueTracker=d0(t))}function Nf(t){if(!t)return!1;var e=t._valueTracker;if(!e)return!0;var l=e.getValue(),n="";return t&&(n=xf(t)?t.checked?"true":"false":t.value),t=n,t!==l?(e.setValue(t),!0):!1}function Ku(t){if(t=t||(typeof document<"u"?document:void 0),typeof t>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var h0=/[\n"\\]/g;function Oe(t){return t.replace(h0,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})}function wr(t,e,l,n,u,r,o,h){t.name="",o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"?t.type=o:t.removeAttribute("type"),e!=null?o==="number"?(e===0&&t.value===""||t.value!=e)&&(t.value=""+Ae(e)):t.value!==""+Ae(e)&&(t.value=""+Ae(e)):o!=="submit"&&o!=="reset"||t.removeAttribute("value"),e!=null?Hr(t,o,Ae(e)):l!=null?Hr(t,o,Ae(l)):n!=null&&t.removeAttribute("value"),u==null&&r!=null&&(t.defaultChecked=!!r),u!=null&&(t.checked=u&&typeof u!="function"&&typeof u!="symbol"),h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"?t.name=""+Ae(h):t.removeAttribute("name")}function wf(t,e,l,n,u,r,o,h){if(r!=null&&typeof r!="function"&&typeof r!="symbol"&&typeof r!="boolean"&&(t.type=r),e!=null||l!=null){if(!(r!=="submit"&&r!=="reset"||e!=null))return;l=l!=null?""+Ae(l):"",e=e!=null?""+Ae(e):l,h||e===t.value||(t.value=e),t.defaultValue=e}n=n??u,n=typeof n!="function"&&typeof n!="symbol"&&!!n,t.checked=h?t.checked:!!n,t.defaultChecked=!!n,o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"&&(t.name=o)}function Hr(t,e,l){e==="number"&&Ku(t.ownerDocument)===t||t.defaultValue===""+l||(t.defaultValue=""+l)}function wn(t,e,l,n){if(t=t.options,e){e={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Qr=!1;if(tl)try{var Ca={};Object.defineProperty(Ca,"passive",{get:function(){Qr=!0}}),window.addEventListener("test",Ca,Ca),window.removeEventListener("test",Ca,Ca)}catch{Qr=!1}var Rl=null,Yr=null,ku=null;function Yf(){if(ku)return ku;var t,e=Yr,l=e.length,n,u="value"in Rl?Rl.value:Rl.textContent,r=u.length;for(t=0;t=Na),Jf=" ",kf=!1;function Ff(t,e){switch(t){case"keyup":return Y0.indexOf(e.keyCode)!==-1;case"keydown":return e.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function $f(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Ln=!1;function X0(t,e){switch(t){case"compositionend":return $f(e);case"keypress":return e.which!==32?null:(kf=!0,Jf);case"textInput":return t=e.data,t===Jf&&kf?null:t;default:return null}}function V0(t,e){if(Ln)return t==="compositionend"||!Kr&&Ff(t,e)?(t=Yf(),ku=Yr=Rl=null,Ln=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(e.ctrlKey||e.altKey||e.metaKey)||e.ctrlKey&&e.altKey){if(e.char&&1=e)return{node:l,offset:e-t};t=n}t:{for(;l;){if(l.nextSibling){l=l.nextSibling;break t}l=l.parentNode}l=void 0}l=ao(l)}}function io(t,e){return t&&e?t===e?!0:t&&t.nodeType===3?!1:e&&e.nodeType===3?io(t,e.parentNode):"contains"in t?t.contains(e):t.compareDocumentPosition?!!(t.compareDocumentPosition(e)&16):!1:!1}function ro(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var e=Ku(t.document);e instanceof t.HTMLIFrameElement;){try{var l=typeof e.contentWindow.location.href=="string"}catch{l=!1}if(l)t=e.contentWindow;else break;e=Ku(t.document)}return e}function Fr(t){var e=t&&t.nodeName&&t.nodeName.toLowerCase();return e&&(e==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||e==="textarea"||t.contentEditable==="true")}var P0=tl&&"documentMode"in document&&11>=document.documentMode,jn=null,$r=null,Ba=null,Wr=!1;function co(t,e,l){var n=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;Wr||jn==null||jn!==Ku(n)||(n=jn,"selectionStart"in n&&Fr(n)?n={start:n.selectionStart,end:n.selectionEnd}:(n=(n.ownerDocument&&n.ownerDocument.defaultView||window).getSelection(),n={anchorNode:n.anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset}),Ba&&qa(Ba,n)||(Ba=n,n=Li($r,"onSelect"),0>=o,u-=o,ll=1<<32-ve(e)+u|l<r?r:8;var o=w.T,h={};w.T=h,qc(t,!1,e,l);try{var g=u(),A=w.S;if(A!==null&&A(h,g),g!==null&&typeof g=="object"&&typeof g.then=="function"){var N=rv(g,n);Pa(t,e,N,Te(t))}else Pa(t,e,n,Te(t))}catch(B){Pa(t,e,{then:function(){},status:"rejected",reason:B},Te())}finally{V.p=r,w.T=o}}function dv(){}function wc(t,e,l,n){if(t.tag!==5)throw Error(s(476));var u=fd(t).queue;sd(t,u,e,tt,l===null?dv:function(){return od(t),l(n)})}function fd(t){var e=t.memoizedState;if(e!==null)return e;e={memoizedState:tt,baseState:tt,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:il,lastRenderedState:tt},next:null};var l={};return e.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:il,lastRenderedState:l},next:null},t.memoizedState=e,t=t.alternate,t!==null&&(t.memoizedState=e),e}function od(t){var e=fd(t).next.queue;Pa(t,e,{},Te())}function Hc(){return Pt(vu)}function dd(){return Lt().memoizedState}function hd(){return Lt().memoizedState}function hv(t){for(var e=t.return;e!==null;){switch(e.tag){case 24:case 3:var l=Te();t=Dl(l);var n=Ml(e,t,l);n!==null&&(Re(n,e,l),Ka(n,e,l)),e={cache:oc()},t.payload=e;return}e=e.return}}function mv(t,e,l){var n=Te();l={lane:n,revertLane:0,action:l,hasEagerState:!1,eagerState:null,next:null},gi(t)?yd(e,l):(l=ec(t,e,l,n),l!==null&&(Re(l,t,n),vd(l,e,n)))}function md(t,e,l){var n=Te();Pa(t,e,l,n)}function Pa(t,e,l,n){var u={lane:n,revertLane:0,action:l,hasEagerState:!1,eagerState:null,next:null};if(gi(t))yd(e,u);else{var r=t.alternate;if(t.lanes===0&&(r===null||r.lanes===0)&&(r=e.lastRenderedReducer,r!==null))try{var o=e.lastRenderedState,h=r(o,l);if(u.hasEagerState=!0,u.eagerState=h,pe(h,o))return ei(t,e,u,0),Dt===null&&ti(),!1}catch{}finally{}if(l=ec(t,e,u,n),l!==null)return Re(l,t,n),vd(l,e,n),!0}return!1}function qc(t,e,l,n){if(n={lane:2,revertLane:ms(),action:n,hasEagerState:!1,eagerState:null,next:null},gi(t)){if(e)throw Error(s(479))}else e=ec(t,l,n,2),e!==null&&Re(e,t,2)}function gi(t){var e=t.alternate;return t===rt||e!==null&&e===rt}function yd(t,e){Fn=di=!0;var l=t.pending;l===null?e.next=e:(e.next=l.next,l.next=e),t.pending=e}function vd(t,e,l){if((l&4194048)!==0){var n=e.lanes;n&=t.pendingLanes,l|=n,e.lanes=l,Rf(t,l)}}var bi={readContext:Pt,use:mi,useCallback:Ht,useContext:Ht,useEffect:Ht,useImperativeHandle:Ht,useLayoutEffect:Ht,useInsertionEffect:Ht,useMemo:Ht,useReducer:Ht,useRef:Ht,useState:Ht,useDebugValue:Ht,useDeferredValue:Ht,useTransition:Ht,useSyncExternalStore:Ht,useId:Ht,useHostTransitionStatus:Ht,useFormState:Ht,useActionState:Ht,useOptimistic:Ht,useMemoCache:Ht,useCacheRefresh:Ht},pd={readContext:Pt,use:mi,useCallback:function(t,e){return se().memoizedState=[t,e===void 0?null:e],t},useContext:Pt,useEffect:td,useImperativeHandle:function(t,e,l){l=l!=null?l.concat([t]):null,pi(4194308,4,ad.bind(null,e,t),l)},useLayoutEffect:function(t,e){return pi(4194308,4,t,e)},useInsertionEffect:function(t,e){pi(4,2,t,e)},useMemo:function(t,e){var l=se();e=e===void 0?null:e;var n=t();if(hn){El(!0);try{t()}finally{El(!1)}}return l.memoizedState=[n,e],n},useReducer:function(t,e,l){var n=se();if(l!==void 0){var u=l(e);if(hn){El(!0);try{l(e)}finally{El(!1)}}}else u=e;return n.memoizedState=n.baseState=u,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:u},n.queue=t,t=t.dispatch=mv.bind(null,rt,t),[n.memoizedState,t]},useRef:function(t){var e=se();return t={current:t},e.memoizedState=t},useState:function(t){t=Cc(t);var e=t.queue,l=md.bind(null,rt,e);return e.dispatch=l,[t.memoizedState,l]},useDebugValue:xc,useDeferredValue:function(t,e){var l=se();return Nc(l,t,e)},useTransition:function(){var t=Cc(!1);return t=sd.bind(null,rt,t.queue,!0,!1),se().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,e,l){var n=rt,u=se();if(pt){if(l===void 0)throw Error(s(407));l=l()}else{if(l=e(),Dt===null)throw Error(s(349));(dt&124)!==0||Lo(n,e,l)}u.memoizedState=l;var r={value:l,getSnapshot:e};return u.queue=r,td(Qo.bind(null,n,r,t),[t]),n.flags|=2048,Wn(9,vi(),jo.bind(null,n,r,l,e),null),l},useId:function(){var t=se(),e=Dt.identifierPrefix;if(pt){var l=nl,n=ll;l=(n&~(1<<32-ve(n)-1)).toString(32)+l,e="«"+e+"R"+l,l=hi++,0I?(Zt=F,F=null):Zt=F.sibling;var yt=D(T,F,R[I],H);if(yt===null){F===null&&(F=Zt);break}t&&F&&yt.alternate===null&&e(T,F),E=r(yt,E,I),ct===null?k=yt:ct.sibling=yt,ct=yt,F=Zt}if(I===R.length)return l(T,F),pt&&rn(T,I),k;if(F===null){for(;II?(Zt=F,F=null):Zt=F.sibling;var Vl=D(T,F,yt.value,H);if(Vl===null){F===null&&(F=Zt);break}t&&F&&Vl.alternate===null&&e(T,F),E=r(Vl,E,I),ct===null?k=Vl:ct.sibling=Vl,ct=Vl,F=Zt}if(yt.done)return l(T,F),pt&&rn(T,I),k;if(F===null){for(;!yt.done;I++,yt=R.next())yt=B(T,yt.value,H),yt!==null&&(E=r(yt,E,I),ct===null?k=yt:ct.sibling=yt,ct=yt);return pt&&rn(T,I),k}for(F=n(F);!yt.done;I++,yt=R.next())yt=M(F,T,I,yt.value,H),yt!==null&&(t&&yt.alternate!==null&&F.delete(yt.key===null?I:yt.key),E=r(yt,E,I),ct===null?k=yt:ct.sibling=yt,ct=yt);return t&&F.forEach(function(vp){return e(T,vp)}),pt&&rn(T,I),k}function Rt(T,E,R,H){if(typeof R=="object"&&R!==null&&R.type===x&&R.key===null&&(R=R.props.children),typeof R=="object"&&R!==null){switch(R.$$typeof){case _:t:{for(var k=R.key;E!==null;){if(E.key===k){if(k=R.type,k===x){if(E.tag===7){l(T,E.sibling),H=u(E,R.props.children),H.return=T,T=H;break t}}else if(E.elementType===k||typeof k=="object"&&k!==null&&k.$$typeof===St&&bd(k)===E.type){l(T,E.sibling),H=u(E,R.props),tu(H,R),H.return=T,T=H;break t}l(T,E);break}else e(T,E);E=E.sibling}R.type===x?(H=an(R.props.children,T.mode,H,R.key),H.return=T,T=H):(H=ni(R.type,R.key,R.props,null,T.mode,H),tu(H,R),H.return=T,T=H)}return o(T);case Y:t:{for(k=R.key;E!==null;){if(E.key===k)if(E.tag===4&&E.stateNode.containerInfo===R.containerInfo&&E.stateNode.implementation===R.implementation){l(T,E.sibling),H=u(E,R.children||[]),H.return=T,T=H;break t}else{l(T,E);break}else e(T,E);E=E.sibling}H=ac(R,T.mode,H),H.return=T,T=H}return o(T);case St:return k=R._init,R=k(R._payload),Rt(T,E,R,H)}if($t(R))return et(T,E,R,H);if(Ft(R)){if(k=Ft(R),typeof k!="function")throw Error(s(150));return R=k.call(R),P(T,E,R,H)}if(typeof R.then=="function")return Rt(T,E,Si(R),H);if(R.$$typeof===G)return Rt(T,E,ri(T,R),H);Ei(T,R)}return typeof R=="string"&&R!==""||typeof R=="number"||typeof R=="bigint"?(R=""+R,E!==null&&E.tag===6?(l(T,E.sibling),H=u(E,R),H.return=T,T=H):(l(T,E),H=nc(R,T.mode,H),H.return=T,T=H),o(T)):l(T,E)}return function(T,E,R,H){try{Ia=0;var k=Rt(T,E,R,H);return Pn=null,k}catch(F){if(F===Va||F===si)throw F;var ct=ge(29,F,null,T.mode);return ct.lanes=H,ct.return=T,ct}finally{}}}var In=Sd(!0),Ed=Sd(!1),Ce=Q(null),Xe=null;function Ul(t){var e=t.alternate;X(Qt,Qt.current&1),X(Ce,t),Xe===null&&(e===null||kn.current!==null||e.memoizedState!==null)&&(Xe=t)}function Td(t){if(t.tag===22){if(X(Qt,Qt.current),X(Ce,t),Xe===null){var e=t.alternate;e!==null&&e.memoizedState!==null&&(Xe=t)}}else Cl()}function Cl(){X(Qt,Qt.current),X(Ce,Ce.current)}function rl(t){K(Ce),Xe===t&&(Xe=null),K(Qt)}var Qt=Q(0);function Ti(t){for(var e=t;e!==null;){if(e.tag===13){var l=e.memoizedState;if(l!==null&&(l=l.dehydrated,l===null||l.data==="$?"||Ds(l)))return e}else if(e.tag===19&&e.memoizedProps.revealOrder!==void 0){if((e.flags&128)!==0)return e}else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break;for(;e.sibling===null;){if(e.return===null||e.return===t)return null;e=e.return}e.sibling.return=e.return,e=e.sibling}return null}function Bc(t,e,l,n){e=t.memoizedState,l=l(n,e),l=l==null?e:b({},e,l),t.memoizedState=l,t.lanes===0&&(t.updateQueue.baseState=l)}var Lc={enqueueSetState:function(t,e,l){t=t._reactInternals;var n=Te(),u=Dl(n);u.payload=e,l!=null&&(u.callback=l),e=Ml(t,u,n),e!==null&&(Re(e,t,n),Ka(e,t,n))},enqueueReplaceState:function(t,e,l){t=t._reactInternals;var n=Te(),u=Dl(n);u.tag=1,u.payload=e,l!=null&&(u.callback=l),e=Ml(t,u,n),e!==null&&(Re(e,t,n),Ka(e,t,n))},enqueueForceUpdate:function(t,e){t=t._reactInternals;var l=Te(),n=Dl(l);n.tag=2,e!=null&&(n.callback=e),e=Ml(t,n,l),e!==null&&(Re(e,t,l),Ka(e,t,l))}};function Rd(t,e,l,n,u,r,o){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(n,r,o):e.prototype&&e.prototype.isPureReactComponent?!qa(l,n)||!qa(u,r):!0}function Ad(t,e,l,n){t=e.state,typeof e.componentWillReceiveProps=="function"&&e.componentWillReceiveProps(l,n),typeof e.UNSAFE_componentWillReceiveProps=="function"&&e.UNSAFE_componentWillReceiveProps(l,n),e.state!==t&&Lc.enqueueReplaceState(e,e.state,null)}function mn(t,e){var l=e;if("ref"in e){l={};for(var n in e)n!=="ref"&&(l[n]=e[n])}if(t=t.defaultProps){l===e&&(l=b({},l));for(var u in t)l[u]===void 0&&(l[u]=t[u])}return l}var Ri=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var e=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof t=="object"&&t!==null&&typeof t.message=="string"?String(t.message):String(t),error:t});if(!window.dispatchEvent(e))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function Od(t){Ri(t)}function Dd(t){console.error(t)}function Md(t){Ri(t)}function Ai(t,e){try{var l=t.onUncaughtError;l(e.value,{componentStack:e.stack})}catch(n){setTimeout(function(){throw n})}}function _d(t,e,l){try{var n=t.onCaughtError;n(l.value,{componentStack:l.stack,errorBoundary:e.tag===1?e.stateNode:null})}catch(u){setTimeout(function(){throw u})}}function jc(t,e,l){return l=Dl(l),l.tag=3,l.payload={element:null},l.callback=function(){Ai(t,e)},l}function Ud(t){return t=Dl(t),t.tag=3,t}function Cd(t,e,l,n){var u=l.type.getDerivedStateFromError;if(typeof u=="function"){var r=n.value;t.payload=function(){return u(r)},t.callback=function(){_d(e,l,n)}}var o=l.stateNode;o!==null&&typeof o.componentDidCatch=="function"&&(t.callback=function(){_d(e,l,n),typeof u!="function"&&(ql===null?ql=new Set([this]):ql.add(this));var h=n.stack;this.componentDidCatch(n.value,{componentStack:h!==null?h:""})})}function vv(t,e,l,n,u){if(l.flags|=32768,n!==null&&typeof n=="object"&&typeof n.then=="function"){if(e=l.alternate,e!==null&&Ya(e,l,u,!0),l=Ce.current,l!==null){switch(l.tag){case 13:return Xe===null?ss():l.alternate===null&&xt===0&&(xt=3),l.flags&=-257,l.flags|=65536,l.lanes=u,n===mc?l.flags|=16384:(e=l.updateQueue,e===null?l.updateQueue=new Set([n]):e.add(n),os(t,n,u)),!1;case 22:return l.flags|=65536,n===mc?l.flags|=16384:(e=l.updateQueue,e===null?(e={transitions:null,markerInstances:null,retryQueue:new Set([n])},l.updateQueue=e):(l=e.retryQueue,l===null?e.retryQueue=new Set([n]):l.add(n)),os(t,n,u)),!1}throw Error(s(435,l.tag))}return os(t,n,u),ss(),!1}if(pt)return e=Ce.current,e!==null?((e.flags&65536)===0&&(e.flags|=256),e.flags|=65536,e.lanes=u,n!==rc&&(t=Error(s(422),{cause:n}),Qa(De(t,l)))):(n!==rc&&(e=Error(s(423),{cause:n}),Qa(De(e,l))),t=t.current.alternate,t.flags|=65536,u&=-u,t.lanes|=u,n=De(n,l),u=jc(t.stateNode,n,u),pc(t,u),xt!==4&&(xt=2)),!1;var r=Error(s(520),{cause:n});if(r=De(r,l),ru===null?ru=[r]:ru.push(r),xt!==4&&(xt=2),e===null)return!0;n=De(n,l),l=e;do{switch(l.tag){case 3:return l.flags|=65536,t=u&-u,l.lanes|=t,t=jc(l.stateNode,n,t),pc(l,t),!1;case 1:if(e=l.type,r=l.stateNode,(l.flags&128)===0&&(typeof e.getDerivedStateFromError=="function"||r!==null&&typeof r.componentDidCatch=="function"&&(ql===null||!ql.has(r))))return l.flags|=65536,u&=-u,l.lanes|=u,u=Ud(u),Cd(u,t,l,n),pc(l,u),!1}l=l.return}while(l!==null);return!1}var zd=Error(s(461)),Xt=!1;function Kt(t,e,l,n){e.child=t===null?Ed(e,null,l,n):In(e,t.child,l,n)}function xd(t,e,l,n,u){l=l.render;var r=e.ref;if("ref"in n){var o={};for(var h in n)h!=="ref"&&(o[h]=n[h])}else o=n;return on(e),n=Tc(t,e,l,o,r,u),h=Rc(),t!==null&&!Xt?(Ac(t,e,u),cl(t,e,u)):(pt&&h&&uc(e),e.flags|=1,Kt(t,e,n,u),e.child)}function Nd(t,e,l,n,u){if(t===null){var r=l.type;return typeof r=="function"&&!lc(r)&&r.defaultProps===void 0&&l.compare===null?(e.tag=15,e.type=r,wd(t,e,r,n,u)):(t=ni(l.type,null,n,e,e.mode,u),t.ref=e.ref,t.return=e,e.child=t)}if(r=t.child,!Jc(t,u)){var o=r.memoizedProps;if(l=l.compare,l=l!==null?l:qa,l(o,n)&&t.ref===e.ref)return cl(t,e,u)}return e.flags|=1,t=el(r,n),t.ref=e.ref,t.return=e,e.child=t}function wd(t,e,l,n,u){if(t!==null){var r=t.memoizedProps;if(qa(r,n)&&t.ref===e.ref)if(Xt=!1,e.pendingProps=n=r,Jc(t,u))(t.flags&131072)!==0&&(Xt=!0);else return e.lanes=t.lanes,cl(t,e,u)}return Qc(t,e,l,n,u)}function Hd(t,e,l){var n=e.pendingProps,u=n.children,r=t!==null?t.memoizedState:null;if(n.mode==="hidden"){if((e.flags&128)!==0){if(n=r!==null?r.baseLanes|l:l,t!==null){for(u=e.child=t.child,r=0;u!==null;)r=r|u.lanes|u.childLanes,u=u.sibling;e.childLanes=r&~n}else e.childLanes=0,e.child=null;return qd(t,e,n,l)}if((l&536870912)!==0)e.memoizedState={baseLanes:0,cachePool:null},t!==null&&ci(e,r!==null?r.cachePool:null),r!==null?wo(e,r):bc(),Td(e);else return e.lanes=e.childLanes=536870912,qd(t,e,r!==null?r.baseLanes|l:l,l)}else r!==null?(ci(e,r.cachePool),wo(e,r),Cl(),e.memoizedState=null):(t!==null&&ci(e,null),bc(),Cl());return Kt(t,e,u,l),e.child}function qd(t,e,l,n){var u=hc();return u=u===null?null:{parent:jt._currentValue,pool:u},e.memoizedState={baseLanes:l,cachePool:u},t!==null&&ci(e,null),bc(),Td(e),t!==null&&Ya(t,e,n,!0),null}function Oi(t,e){var l=e.ref;if(l===null)t!==null&&t.ref!==null&&(e.flags|=4194816);else{if(typeof l!="function"&&typeof l!="object")throw Error(s(284));(t===null||t.ref!==l)&&(e.flags|=4194816)}}function Qc(t,e,l,n,u){return on(e),l=Tc(t,e,l,n,void 0,u),n=Rc(),t!==null&&!Xt?(Ac(t,e,u),cl(t,e,u)):(pt&&n&&uc(e),e.flags|=1,Kt(t,e,l,u),e.child)}function Bd(t,e,l,n,u,r){return on(e),e.updateQueue=null,l=qo(e,n,l,u),Ho(t),n=Rc(),t!==null&&!Xt?(Ac(t,e,r),cl(t,e,r)):(pt&&n&&uc(e),e.flags|=1,Kt(t,e,l,r),e.child)}function Ld(t,e,l,n,u){if(on(e),e.stateNode===null){var r=Xn,o=l.contextType;typeof o=="object"&&o!==null&&(r=Pt(o)),r=new l(n,r),e.memoizedState=r.state!==null&&r.state!==void 0?r.state:null,r.updater=Lc,e.stateNode=r,r._reactInternals=e,r=e.stateNode,r.props=n,r.state=e.memoizedState,r.refs={},yc(e),o=l.contextType,r.context=typeof o=="object"&&o!==null?Pt(o):Xn,r.state=e.memoizedState,o=l.getDerivedStateFromProps,typeof o=="function"&&(Bc(e,l,o,n),r.state=e.memoizedState),typeof l.getDerivedStateFromProps=="function"||typeof r.getSnapshotBeforeUpdate=="function"||typeof r.UNSAFE_componentWillMount!="function"&&typeof r.componentWillMount!="function"||(o=r.state,typeof r.componentWillMount=="function"&&r.componentWillMount(),typeof r.UNSAFE_componentWillMount=="function"&&r.UNSAFE_componentWillMount(),o!==r.state&&Lc.enqueueReplaceState(r,r.state,null),ka(e,n,r,u),Ja(),r.state=e.memoizedState),typeof r.componentDidMount=="function"&&(e.flags|=4194308),n=!0}else if(t===null){r=e.stateNode;var h=e.memoizedProps,g=mn(l,h);r.props=g;var A=r.context,N=l.contextType;o=Xn,typeof N=="object"&&N!==null&&(o=Pt(N));var B=l.getDerivedStateFromProps;N=typeof B=="function"||typeof r.getSnapshotBeforeUpdate=="function",h=e.pendingProps!==h,N||typeof r.UNSAFE_componentWillReceiveProps!="function"&&typeof r.componentWillReceiveProps!="function"||(h||A!==o)&&Ad(e,r,n,o),Ol=!1;var D=e.memoizedState;r.state=D,ka(e,n,r,u),Ja(),A=e.memoizedState,h||D!==A||Ol?(typeof B=="function"&&(Bc(e,l,B,n),A=e.memoizedState),(g=Ol||Rd(e,l,g,n,D,A,o))?(N||typeof r.UNSAFE_componentWillMount!="function"&&typeof r.componentWillMount!="function"||(typeof r.componentWillMount=="function"&&r.componentWillMount(),typeof r.UNSAFE_componentWillMount=="function"&&r.UNSAFE_componentWillMount()),typeof r.componentDidMount=="function"&&(e.flags|=4194308)):(typeof r.componentDidMount=="function"&&(e.flags|=4194308),e.memoizedProps=n,e.memoizedState=A),r.props=n,r.state=A,r.context=o,n=g):(typeof r.componentDidMount=="function"&&(e.flags|=4194308),n=!1)}else{r=e.stateNode,vc(t,e),o=e.memoizedProps,N=mn(l,o),r.props=N,B=e.pendingProps,D=r.context,A=l.contextType,g=Xn,typeof A=="object"&&A!==null&&(g=Pt(A)),h=l.getDerivedStateFromProps,(A=typeof h=="function"||typeof r.getSnapshotBeforeUpdate=="function")||typeof r.UNSAFE_componentWillReceiveProps!="function"&&typeof r.componentWillReceiveProps!="function"||(o!==B||D!==g)&&Ad(e,r,n,g),Ol=!1,D=e.memoizedState,r.state=D,ka(e,n,r,u),Ja();var M=e.memoizedState;o!==B||D!==M||Ol||t!==null&&t.dependencies!==null&&ii(t.dependencies)?(typeof h=="function"&&(Bc(e,l,h,n),M=e.memoizedState),(N=Ol||Rd(e,l,N,n,D,M,g)||t!==null&&t.dependencies!==null&&ii(t.dependencies))?(A||typeof r.UNSAFE_componentWillUpdate!="function"&&typeof r.componentWillUpdate!="function"||(typeof r.componentWillUpdate=="function"&&r.componentWillUpdate(n,M,g),typeof r.UNSAFE_componentWillUpdate=="function"&&r.UNSAFE_componentWillUpdate(n,M,g)),typeof r.componentDidUpdate=="function"&&(e.flags|=4),typeof r.getSnapshotBeforeUpdate=="function"&&(e.flags|=1024)):(typeof r.componentDidUpdate!="function"||o===t.memoizedProps&&D===t.memoizedState||(e.flags|=4),typeof r.getSnapshotBeforeUpdate!="function"||o===t.memoizedProps&&D===t.memoizedState||(e.flags|=1024),e.memoizedProps=n,e.memoizedState=M),r.props=n,r.state=M,r.context=g,n=N):(typeof r.componentDidUpdate!="function"||o===t.memoizedProps&&D===t.memoizedState||(e.flags|=4),typeof r.getSnapshotBeforeUpdate!="function"||o===t.memoizedProps&&D===t.memoizedState||(e.flags|=1024),n=!1)}return r=n,Oi(t,e),n=(e.flags&128)!==0,r||n?(r=e.stateNode,l=n&&typeof l.getDerivedStateFromError!="function"?null:r.render(),e.flags|=1,t!==null&&n?(e.child=In(e,t.child,null,u),e.child=In(e,null,l,u)):Kt(t,e,l,u),e.memoizedState=r.state,t=e.child):t=cl(t,e,u),t}function jd(t,e,l,n){return ja(),e.flags|=256,Kt(t,e,l,n),e.child}var Yc={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Gc(t){return{baseLanes:t,cachePool:Do()}}function Xc(t,e,l){return t=t!==null?t.childLanes&~l:0,e&&(t|=ze),t}function Qd(t,e,l){var n=e.pendingProps,u=!1,r=(e.flags&128)!==0,o;if((o=r)||(o=t!==null&&t.memoizedState===null?!1:(Qt.current&2)!==0),o&&(u=!0,e.flags&=-129),o=(e.flags&32)!==0,e.flags&=-33,t===null){if(pt){if(u?Ul(e):Cl(),pt){var h=zt,g;if(g=h){t:{for(g=h,h=Ge;g.nodeType!==8;){if(!h){h=null;break t}if(g=qe(g.nextSibling),g===null){h=null;break t}}h=g}h!==null?(e.memoizedState={dehydrated:h,treeContext:un!==null?{id:ll,overflow:nl}:null,retryLane:536870912,hydrationErrors:null},g=ge(18,null,null,0),g.stateNode=h,g.return=e,e.child=g,ae=e,zt=null,g=!0):g=!1}g||sn(e)}if(h=e.memoizedState,h!==null&&(h=h.dehydrated,h!==null))return Ds(h)?e.lanes=32:e.lanes=536870912,null;rl(e)}return h=n.children,n=n.fallback,u?(Cl(),u=e.mode,h=Di({mode:"hidden",children:h},u),n=an(n,u,l,null),h.return=e,n.return=e,h.sibling=n,e.child=h,u=e.child,u.memoizedState=Gc(l),u.childLanes=Xc(t,o,l),e.memoizedState=Yc,n):(Ul(e),Vc(e,h))}if(g=t.memoizedState,g!==null&&(h=g.dehydrated,h!==null)){if(r)e.flags&256?(Ul(e),e.flags&=-257,e=Zc(t,e,l)):e.memoizedState!==null?(Cl(),e.child=t.child,e.flags|=128,e=null):(Cl(),u=n.fallback,h=e.mode,n=Di({mode:"visible",children:n.children},h),u=an(u,h,l,null),u.flags|=2,n.return=e,u.return=e,n.sibling=u,e.child=n,In(e,t.child,null,l),n=e.child,n.memoizedState=Gc(l),n.childLanes=Xc(t,o,l),e.memoizedState=Yc,e=u);else if(Ul(e),Ds(h)){if(o=h.nextSibling&&h.nextSibling.dataset,o)var A=o.dgst;o=A,n=Error(s(419)),n.stack="",n.digest=o,Qa({value:n,source:null,stack:null}),e=Zc(t,e,l)}else if(Xt||Ya(t,e,l,!1),o=(l&t.childLanes)!==0,Xt||o){if(o=Dt,o!==null&&(n=l&-l,n=(n&42)!==0?1:Mr(n),n=(n&(o.suspendedLanes|l))!==0?0:n,n!==0&&n!==g.retryLane))throw g.retryLane=n,Gn(t,n),Re(o,t,n),zd;h.data==="$?"||ss(),e=Zc(t,e,l)}else h.data==="$?"?(e.flags|=192,e.child=t.child,e=null):(t=g.treeContext,zt=qe(h.nextSibling),ae=e,pt=!0,cn=null,Ge=!1,t!==null&&(_e[Ue++]=ll,_e[Ue++]=nl,_e[Ue++]=un,ll=t.id,nl=t.overflow,un=e),e=Vc(e,n.children),e.flags|=4096);return e}return u?(Cl(),u=n.fallback,h=e.mode,g=t.child,A=g.sibling,n=el(g,{mode:"hidden",children:n.children}),n.subtreeFlags=g.subtreeFlags&65011712,A!==null?u=el(A,u):(u=an(u,h,l,null),u.flags|=2),u.return=e,n.return=e,n.sibling=u,e.child=n,n=u,u=e.child,h=t.child.memoizedState,h===null?h=Gc(l):(g=h.cachePool,g!==null?(A=jt._currentValue,g=g.parent!==A?{parent:A,pool:A}:g):g=Do(),h={baseLanes:h.baseLanes|l,cachePool:g}),u.memoizedState=h,u.childLanes=Xc(t,o,l),e.memoizedState=Yc,n):(Ul(e),l=t.child,t=l.sibling,l=el(l,{mode:"visible",children:n.children}),l.return=e,l.sibling=null,t!==null&&(o=e.deletions,o===null?(e.deletions=[t],e.flags|=16):o.push(t)),e.child=l,e.memoizedState=null,l)}function Vc(t,e){return e=Di({mode:"visible",children:e},t.mode),e.return=t,t.child=e}function Di(t,e){return t=ge(22,t,null,e),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function Zc(t,e,l){return In(e,t.child,null,l),t=Vc(e,e.pendingProps.children),t.flags|=2,e.memoizedState=null,t}function Yd(t,e,l){t.lanes|=e;var n=t.alternate;n!==null&&(n.lanes|=e),sc(t.return,e,l)}function Kc(t,e,l,n,u){var r=t.memoizedState;r===null?t.memoizedState={isBackwards:e,rendering:null,renderingStartTime:0,last:n,tail:l,tailMode:u}:(r.isBackwards=e,r.rendering=null,r.renderingStartTime=0,r.last=n,r.tail=l,r.tailMode=u)}function Gd(t,e,l){var n=e.pendingProps,u=n.revealOrder,r=n.tail;if(Kt(t,e,n.children,l),n=Qt.current,(n&2)!==0)n=n&1|2,e.flags|=128;else{if(t!==null&&(t.flags&128)!==0)t:for(t=e.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&Yd(t,l,e);else if(t.tag===19)Yd(t,l,e);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break t;for(;t.sibling===null;){if(t.return===null||t.return===e)break t;t=t.return}t.sibling.return=t.return,t=t.sibling}n&=1}switch(X(Qt,n),u){case"forwards":for(l=e.child,u=null;l!==null;)t=l.alternate,t!==null&&Ti(t)===null&&(u=l),l=l.sibling;l=u,l===null?(u=e.child,e.child=null):(u=l.sibling,l.sibling=null),Kc(e,!1,u,l,r);break;case"backwards":for(l=null,u=e.child,e.child=null;u!==null;){if(t=u.alternate,t!==null&&Ti(t)===null){e.child=u;break}t=u.sibling,u.sibling=l,l=u,u=t}Kc(e,!0,l,null,r);break;case"together":Kc(e,!1,null,null,void 0);break;default:e.memoizedState=null}return e.child}function cl(t,e,l){if(t!==null&&(e.dependencies=t.dependencies),Hl|=e.lanes,(l&e.childLanes)===0)if(t!==null){if(Ya(t,e,l,!1),(l&e.childLanes)===0)return null}else return null;if(t!==null&&e.child!==t.child)throw Error(s(153));if(e.child!==null){for(t=e.child,l=el(t,t.pendingProps),e.child=l,l.return=e;t.sibling!==null;)t=t.sibling,l=l.sibling=el(t,t.pendingProps),l.return=e;l.sibling=null}return e.child}function Jc(t,e){return(t.lanes&e)!==0?!0:(t=t.dependencies,!!(t!==null&&ii(t)))}function pv(t,e,l){switch(e.tag){case 3:_t(e,e.stateNode.containerInfo),Al(e,jt,t.memoizedState.cache),ja();break;case 27:case 5:Tr(e);break;case 4:_t(e,e.stateNode.containerInfo);break;case 10:Al(e,e.type,e.memoizedProps.value);break;case 13:var n=e.memoizedState;if(n!==null)return n.dehydrated!==null?(Ul(e),e.flags|=128,null):(l&e.child.childLanes)!==0?Qd(t,e,l):(Ul(e),t=cl(t,e,l),t!==null?t.sibling:null);Ul(e);break;case 19:var u=(t.flags&128)!==0;if(n=(l&e.childLanes)!==0,n||(Ya(t,e,l,!1),n=(l&e.childLanes)!==0),u){if(n)return Gd(t,e,l);e.flags|=128}if(u=e.memoizedState,u!==null&&(u.rendering=null,u.tail=null,u.lastEffect=null),X(Qt,Qt.current),n)break;return null;case 22:case 23:return e.lanes=0,Hd(t,e,l);case 24:Al(e,jt,t.memoizedState.cache)}return cl(t,e,l)}function Xd(t,e,l){if(t!==null)if(t.memoizedProps!==e.pendingProps)Xt=!0;else{if(!Jc(t,l)&&(e.flags&128)===0)return Xt=!1,pv(t,e,l);Xt=(t.flags&131072)!==0}else Xt=!1,pt&&(e.flags&1048576)!==0&&bo(e,ui,e.index);switch(e.lanes=0,e.tag){case 16:t:{t=e.pendingProps;var n=e.elementType,u=n._init;if(n=u(n._payload),e.type=n,typeof n=="function")lc(n)?(t=mn(n,t),e.tag=1,e=Ld(null,e,n,t,l)):(e.tag=0,e=Qc(null,e,n,t,l));else{if(n!=null){if(u=n.$$typeof,u===$){e.tag=11,e=xd(null,e,n,t,l);break t}else if(u===ht){e.tag=14,e=Nd(null,e,n,t,l);break t}}throw e=Pl(n)||n,Error(s(306,e,""))}}return e;case 0:return Qc(t,e,e.type,e.pendingProps,l);case 1:return n=e.type,u=mn(n,e.pendingProps),Ld(t,e,n,u,l);case 3:t:{if(_t(e,e.stateNode.containerInfo),t===null)throw Error(s(387));n=e.pendingProps;var r=e.memoizedState;u=r.element,vc(t,e),ka(e,n,null,l);var o=e.memoizedState;if(n=o.cache,Al(e,jt,n),n!==r.cache&&fc(e,[jt],l,!0),Ja(),n=o.element,r.isDehydrated)if(r={element:n,isDehydrated:!1,cache:o.cache},e.updateQueue.baseState=r,e.memoizedState=r,e.flags&256){e=jd(t,e,n,l);break t}else if(n!==u){u=De(Error(s(424)),e),Qa(u),e=jd(t,e,n,l);break t}else{switch(t=e.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(zt=qe(t.firstChild),ae=e,pt=!0,cn=null,Ge=!0,l=Ed(e,null,n,l),e.child=l;l;)l.flags=l.flags&-3|4096,l=l.sibling}else{if(ja(),n===u){e=cl(t,e,l);break t}Kt(t,e,n,l)}e=e.child}return e;case 26:return Oi(t,e),t===null?(l=Jh(e.type,null,e.pendingProps,null))?e.memoizedState=l:pt||(l=e.type,t=e.pendingProps,n=Qi(nt.current).createElement(l),n[Wt]=e,n[re]=t,kt(n,l,t),Gt(n),e.stateNode=n):e.memoizedState=Jh(e.type,t.memoizedProps,e.pendingProps,t.memoizedState),null;case 27:return Tr(e),t===null&&pt&&(n=e.stateNode=Vh(e.type,e.pendingProps,nt.current),ae=e,Ge=!0,u=zt,jl(e.type)?(Ms=u,zt=qe(n.firstChild)):zt=u),Kt(t,e,e.pendingProps.children,l),Oi(t,e),t===null&&(e.flags|=4194304),e.child;case 5:return t===null&&pt&&((u=n=zt)&&(n=Zv(n,e.type,e.pendingProps,Ge),n!==null?(e.stateNode=n,ae=e,zt=qe(n.firstChild),Ge=!1,u=!0):u=!1),u||sn(e)),Tr(e),u=e.type,r=e.pendingProps,o=t!==null?t.memoizedProps:null,n=r.children,Rs(u,r)?n=null:o!==null&&Rs(u,o)&&(e.flags|=32),e.memoizedState!==null&&(u=Tc(t,e,sv,null,null,l),vu._currentValue=u),Oi(t,e),Kt(t,e,n,l),e.child;case 6:return t===null&&pt&&((t=l=zt)&&(l=Kv(l,e.pendingProps,Ge),l!==null?(e.stateNode=l,ae=e,zt=null,t=!0):t=!1),t||sn(e)),null;case 13:return Qd(t,e,l);case 4:return _t(e,e.stateNode.containerInfo),n=e.pendingProps,t===null?e.child=In(e,null,n,l):Kt(t,e,n,l),e.child;case 11:return xd(t,e,e.type,e.pendingProps,l);case 7:return Kt(t,e,e.pendingProps,l),e.child;case 8:return Kt(t,e,e.pendingProps.children,l),e.child;case 12:return Kt(t,e,e.pendingProps.children,l),e.child;case 10:return n=e.pendingProps,Al(e,e.type,n.value),Kt(t,e,n.children,l),e.child;case 9:return u=e.type._context,n=e.pendingProps.children,on(e),u=Pt(u),n=n(u),e.flags|=1,Kt(t,e,n,l),e.child;case 14:return Nd(t,e,e.type,e.pendingProps,l);case 15:return wd(t,e,e.type,e.pendingProps,l);case 19:return Gd(t,e,l);case 31:return n=e.pendingProps,l=e.mode,n={mode:n.mode,children:n.children},t===null?(l=Di(n,l),l.ref=e.ref,e.child=l,l.return=e,e=l):(l=el(t.child,n),l.ref=e.ref,e.child=l,l.return=e,e=l),e;case 22:return Hd(t,e,l);case 24:return on(e),n=Pt(jt),t===null?(u=hc(),u===null&&(u=Dt,r=oc(),u.pooledCache=r,r.refCount++,r!==null&&(u.pooledCacheLanes|=l),u=r),e.memoizedState={parent:n,cache:u},yc(e),Al(e,jt,u)):((t.lanes&l)!==0&&(vc(t,e),ka(e,null,null,l),Ja()),u=t.memoizedState,r=e.memoizedState,u.parent!==n?(u={parent:n,cache:n},e.memoizedState=u,e.lanes===0&&(e.memoizedState=e.updateQueue.baseState=u),Al(e,jt,n)):(n=r.cache,Al(e,jt,n),n!==u.cache&&fc(e,[jt],l,!0))),Kt(t,e,e.pendingProps.children,l),e.child;case 29:throw e.pendingProps}throw Error(s(156,e.tag))}function sl(t){t.flags|=4}function Vd(t,e){if(e.type!=="stylesheet"||(e.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!Ph(e)){if(e=Ce.current,e!==null&&((dt&4194048)===dt?Xe!==null:(dt&62914560)!==dt&&(dt&536870912)===0||e!==Xe))throw Za=mc,Mo;t.flags|=8192}}function Mi(t,e){e!==null&&(t.flags|=4),t.flags&16384&&(e=t.tag!==22?Ef():536870912,t.lanes|=e,na|=e)}function eu(t,e){if(!pt)switch(t.tailMode){case"hidden":e=t.tail;for(var l=null;e!==null;)e.alternate!==null&&(l=e),e=e.sibling;l===null?t.tail=null:l.sibling=null;break;case"collapsed":l=t.tail;for(var n=null;l!==null;)l.alternate!==null&&(n=l),l=l.sibling;n===null?e||t.tail===null?t.tail=null:t.tail.sibling=null:n.sibling=null}}function Ct(t){var e=t.alternate!==null&&t.alternate.child===t.child,l=0,n=0;if(e)for(var u=t.child;u!==null;)l|=u.lanes|u.childLanes,n|=u.subtreeFlags&65011712,n|=u.flags&65011712,u.return=t,u=u.sibling;else for(u=t.child;u!==null;)l|=u.lanes|u.childLanes,n|=u.subtreeFlags,n|=u.flags,u.return=t,u=u.sibling;return t.subtreeFlags|=n,t.childLanes=l,e}function gv(t,e,l){var n=e.pendingProps;switch(ic(e),e.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Ct(e),null;case 1:return Ct(e),null;case 3:return l=e.stateNode,n=null,t!==null&&(n=t.memoizedState.cache),e.memoizedState.cache!==n&&(e.flags|=2048),ul(jt),Sl(),l.pendingContext&&(l.context=l.pendingContext,l.pendingContext=null),(t===null||t.child===null)&&(La(e)?sl(e):t===null||t.memoizedState.isDehydrated&&(e.flags&256)===0||(e.flags|=1024,To())),Ct(e),null;case 26:return l=e.memoizedState,t===null?(sl(e),l!==null?(Ct(e),Vd(e,l)):(Ct(e),e.flags&=-16777217)):l?l!==t.memoizedState?(sl(e),Ct(e),Vd(e,l)):(Ct(e),e.flags&=-16777217):(t.memoizedProps!==n&&sl(e),Ct(e),e.flags&=-16777217),null;case 27:Lu(e),l=nt.current;var u=e.type;if(t!==null&&e.stateNode!=null)t.memoizedProps!==n&&sl(e);else{if(!n){if(e.stateNode===null)throw Error(s(166));return Ct(e),null}t=W.current,La(e)?So(e):(t=Vh(u,n,l),e.stateNode=t,sl(e))}return Ct(e),null;case 5:if(Lu(e),l=e.type,t!==null&&e.stateNode!=null)t.memoizedProps!==n&&sl(e);else{if(!n){if(e.stateNode===null)throw Error(s(166));return Ct(e),null}if(t=W.current,La(e))So(e);else{switch(u=Qi(nt.current),t){case 1:t=u.createElementNS("http://www.w3.org/2000/svg",l);break;case 2:t=u.createElementNS("http://www.w3.org/1998/Math/MathML",l);break;default:switch(l){case"svg":t=u.createElementNS("http://www.w3.org/2000/svg",l);break;case"math":t=u.createElementNS("http://www.w3.org/1998/Math/MathML",l);break;case"script":t=u.createElement("div"),t.innerHTML=" + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5e27c30 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4504 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.84.1", + "axios": "^1.11.0", + "lucide-react": "^0.536.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.7.1" + }, + "devDependencies": { + "@eslint/js": "^9.30.1", + "@tailwindcss/postcss": "^4.1.11", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "typescript": "~5.8.3", + "typescript-eslint": "^8.35.1", + "vite": "^7.0.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", + "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", + "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", + "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", + "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", + "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", + "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", + "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", + "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", + "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", + "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", + "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", + "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", + "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", + "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", + "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", + "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", + "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", + "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", + "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", + "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", + "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", + "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", + "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.11" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", + "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-x64": "4.1.11", + "@tailwindcss/oxide-freebsd-x64": "4.1.11", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-x64-musl": "4.1.11", + "@tailwindcss/oxide-wasm32-wasi": "4.1.11", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", + "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", + "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", + "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", + "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", + "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", + "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", + "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", + "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", + "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", + "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.11", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", + "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", + "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz", + "integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.11", + "@tailwindcss/oxide": "4.1.11", + "postcss": "^8.4.41", + "tailwindcss": "4.1.11" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.83.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.1.tgz", + "integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.84.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.84.1.tgz", + "integrity": "sha512-zo7EUygcWJMQfFNWDSG7CBhy8irje/XY0RDVKKV4IQJAysb+ZJkkJPcnQi+KboyGUgT+SQebRFoTqLuTtfoDLw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.83.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", + "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.38.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.194", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.194.tgz", + "integrity": "sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", + "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.32.0", + "@eslint/plugin-kit": "^0.3.4", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.536.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz", + "integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz", + "integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.7.1.tgz", + "integrity": "sha512-bavdk2BA5r3MYalGKZ01u8PGuDBloQmzpBZVhDLrOOv1N943Wq6dcM9GhB3x8b7AbqPMEezauv4PeGkAJfy7FQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.7.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", + "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.2", + "@rollup/rollup-android-arm64": "4.46.2", + "@rollup/rollup-darwin-arm64": "4.46.2", + "@rollup/rollup-darwin-x64": "4.46.2", + "@rollup/rollup-freebsd-arm64": "4.46.2", + "@rollup/rollup-freebsd-x64": "4.46.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", + "@rollup/rollup-linux-arm-musleabihf": "4.46.2", + "@rollup/rollup-linux-arm64-gnu": "4.46.2", + "@rollup/rollup-linux-arm64-musl": "4.46.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", + "@rollup/rollup-linux-ppc64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-musl": "4.46.2", + "@rollup/rollup-linux-s390x-gnu": "4.46.2", + "@rollup/rollup-linux-x64-gnu": "4.46.2", + "@rollup/rollup-linux-x64-musl": "4.46.2", + "@rollup/rollup-win32-arm64-msvc": "4.46.2", + "@rollup/rollup-win32-ia32-msvc": "4.46.2", + "@rollup/rollup-win32-x64-msvc": "4.46.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", + "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.38.0", + "@typescript-eslint/parser": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", + "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f59673d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.84.1", + "axios": "^1.11.0", + "lucide-react": "^0.536.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.7.1" + }, + "devDependencies": { + "@eslint/js": "^9.30.1", + "@tailwindcss/postcss": "^4.1.11", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "typescript": "~5.8.3", + "typescript-eslint": "^8.35.1", + "vite": "^7.0.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..af9d8dc --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..d523794 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import Layout from './components/Layout'; +import Dashboard from './pages/Dashboard'; +import Articles from './pages/Articles'; +import Feeds from './pages/Feeds'; +import Settings from './pages/Settings'; + +const App: React.FC = () => { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +}; + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..161cc32 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { + Home, + FileText, + Rss, + Settings as SettingsIcon, + Menu, + X +} from 'lucide-react'; + +interface LayoutProps { + children: React.ReactNode; +} + +const Layout: React.FC = ({ children }) => { + const [sidebarOpen, setSidebarOpen] = React.useState(false); + const location = useLocation(); + + const navigation = [ + { name: 'Dashboard', href: '/dashboard', icon: Home }, + { name: 'Articles', href: '/articles', icon: FileText }, + { name: 'Feeds', href: '/feeds', icon: Rss }, + { name: 'Settings', href: '/settings', icon: SettingsIcon }, + ]; + + const renderMobileOverlay = () => { + if (!sidebarOpen) return null; + + return ( +
setSidebarOpen(false)} + /> + ); + }; + + return ( +
+ {renderMobileOverlay()} + + {/* Mobile sidebar */} +
+
+

FFR

+ +
+ +
+ + {/* Desktop sidebar */} +
+
+
+

FFR

+
+ +
+
+
+

+ Feed Feed Reader +

+

+ Admin Dashboard +

+
+
+
+
+
+ + {/* Main content */} +
+
+ +
+

FFR

+
+
+
+ {children} +
+
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..6feaca7 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,12 @@ +@import "tailwindcss"; + +@source "./src/**/*.{js,ts,jsx,tsx}"; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..1404909 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,210 @@ +import axios from 'axios'; + +// Configure axios base URL for API calls +axios.defaults.baseURL = '/api/v1'; + +// Types for API responses +export interface ApiResponse { + success: boolean; + data: T; + message: string; +} + +export interface ApiError { + success: false; + message: string; + errors?: Record; +} + +export interface PaginatedResponse { + data: T[]; + pagination: { + current_page: number; + last_page: number; + per_page: number; + total: number; + from: number | null; + to: number | null; + }; +} + + +// Article types +export interface Article { + id: number; + feed_id: number; + url: string; + title: string; + description: string; + is_valid: boolean; + is_duplicate: boolean; + approval_status: 'pending' | 'approved' | 'rejected'; + approved_at: string | null; + approved_by: string | null; + fetched_at: string | null; + validated_at: string | null; + created_at: string; + updated_at: string; + feed?: Feed; + article_publication?: ArticlePublication; +} + +// Feed types +export interface Feed { + id: number; + name: string; + url: string; + type: 'website' | 'rss'; + is_active: boolean; + description: string | null; + created_at: string; + updated_at: string; + articles_count?: number; +} + +// Other types +export interface ArticlePublication { + id: number; + article_id: number; + status: string; + published_at: string | null; + created_at: string; + updated_at: string; +} + +export interface PlatformAccount { + id: number; + platform_instance_id: number; + account_id: string; + username: string; + display_name: string | null; + description: string | null; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface PlatformChannel { + id: number; + platform_instance_id: number; + channel_id: string; + name: string; + display_name: string | null; + description: string | null; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface Settings { + article_processing_enabled: boolean; + publishing_approvals_enabled: boolean; +} + +export interface DashboardStats { + article_stats: { + total_today: number; + total_week: number; + total_month: number; + approved_today: number; + approved_week: number; + approved_month: number; + approval_percentage_today: number; + approval_percentage_week: number; + approval_percentage_month: number; + }; + system_stats: { + total_feeds: number; + active_feeds: number; + total_platform_accounts: number; + active_platform_accounts: number; + total_platform_channels: number; + active_platform_channels: number; + total_routes: number; + active_routes: number; + }; + available_periods: Array<{ value: string; label: string }>; + current_period: string; +} + +// API Client class +class ApiClient { + constructor() { + this.setupInterceptors(); + } + + private setupInterceptors() { + // Response interceptor to handle errors + axios.interceptors.response.use( + (response) => response, + (error) => { + console.error('API Error:', error); + return Promise.reject(error); + } + ); + } + + // Dashboard endpoints + async getDashboardStats(period = 'today'): Promise { + const response = await axios.get>('/dashboard/stats', { + params: { period } + }); + return response.data.data; + } + + // Articles endpoints + async getArticles(page = 1, perPage = 15): Promise<{ articles: Article[]; pagination: any; settings: any }> { + const response = await axios.get>('/articles', { + params: { page, per_page: perPage } + }); + return response.data.data; + } + + async approveArticle(articleId: number): Promise
{ + const response = await axios.post>(`/articles/${articleId}/approve`); + return response.data.data; + } + + async rejectArticle(articleId: number): Promise
{ + const response = await axios.post>(`/articles/${articleId}/reject`); + return response.data.data; + } + + // Feeds endpoints + async getFeeds(): Promise { + const response = await axios.get>('/feeds'); + return response.data.data; + } + + async createFeed(data: Partial): Promise { + const response = await axios.post>('/feeds', data); + return response.data.data; + } + + async updateFeed(id: number, data: Partial): Promise { + const response = await axios.put>(`/feeds/${id}`, data); + return response.data.data; + } + + async deleteFeed(id: number): Promise { + await axios.delete(`/feeds/${id}`); + } + + async toggleFeed(id: number): Promise { + const response = await axios.post>(`/feeds/${id}/toggle`); + return response.data.data; + } + + // Settings endpoints + async getSettings(): Promise { + const response = await axios.get>('/settings'); + return response.data.data; + } + + async updateSettings(data: Partial): Promise { + const response = await axios.put>('/settings', data); + return response.data.data; + } +} + +export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2e18edd --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import './index.css'; +import App from './App'; + +// Create React Query client +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/frontend/src/pages/Articles.tsx b/frontend/src/pages/Articles.tsx new file mode 100644 index 0000000..7fa1409 --- /dev/null +++ b/frontend/src/pages/Articles.tsx @@ -0,0 +1,249 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { CheckCircle, XCircle, ExternalLink, Calendar, Tag, FileText } from 'lucide-react'; +import { apiClient, type Article } from '../lib/api'; + +const Articles: React.FC = () => { + const [page, setPage] = useState(1); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ['articles', page], + queryFn: () => apiClient.getArticles(page), + }); + + const approveMutation = useMutation({ + mutationFn: (articleId: number) => apiClient.approveArticle(articleId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['articles'] }); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: (articleId: number) => apiClient.rejectArticle(articleId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['articles'] }); + }, + }); + + const handleApprove = (articleId: number) => { + approveMutation.mutate(articleId); + }; + + const handleReject = (articleId: number) => { + rejectMutation.mutate(articleId); + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'approved': + return ( + + + Approved + + ); + case 'rejected': + return ( + + + Rejected + + ); + default: + return ( + + + Pending + + ); + } + }; + + if (isLoading) { + return ( +
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+

Failed to load articles

+
+
+ ); + } + + const articles = data?.articles || []; + const pagination = data?.pagination; + const settings = data?.settings; + + return ( +
+
+

Articles

+

+ Manage and review articles from your feeds +

+ {settings?.publishing_approvals_enabled && ( +
+ + Approval system enabled +
+ )} +
+ +
+ {articles.map((article: Article) => ( +
+
+
+

+ {article.title || 'Untitled Article'} +

+

+ {article.description || 'No description available'} +

+
+ Feed: {article.feed?.name || 'Unknown'} + + {new Date(article.created_at).toLocaleDateString()} + {article.is_valid !== null && ( + <> + + + {article.is_valid ? 'Valid' : 'Invalid'} + + + )} + {article.is_duplicate && ( + <> + + Duplicate + + )} +
+
+
+ {getStatusBadge(article.approval_status)} + + + +
+
+ + {article.approval_status === 'pending' && settings?.publishing_approvals_enabled && ( +
+ + +
+ )} +
+ ))} + + {articles.length === 0 && ( +
+ +

No articles

+

+ No articles have been fetched yet. +

+
+ )} + + {/* Pagination */} + {pagination && pagination.last_page > 1 && ( +
+
+ + +
+
+
+

+ Showing{' '} + {pagination.from} to{' '} + {pagination.to} of{' '} + {pagination.total} results +

+
+
+ +
+
+
+ )} +
+
+ ); +}; + +export default Articles; \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..24a130e --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { FileText, Rss, Users, Route, TrendingUp, Clock, CheckCircle } from 'lucide-react'; +import { apiClient } from '../lib/api'; + +const Dashboard: React.FC = () => { + const { data: stats, isLoading, error } = useQuery({ + queryKey: ['dashboard-stats'], + queryFn: () => apiClient.getDashboardStats(), + }); + + if (isLoading) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+

Failed to load dashboard data

+
+
+ ); + } + + const articleStats = stats?.article_stats; + const systemStats = stats?.system_stats; + + return ( +
+
+

Dashboard

+

+ Overview of your feed management system +

+
+ + {/* Article Statistics */} +
+

Article Statistics

+
+
+
+
+ +
+
+

Articles Today

+

+ {articleStats?.total_today || 0} +

+
+
+
+ +
+
+
+ +
+
+

Articles This Week

+

+ {articleStats?.total_week || 0} +

+
+
+
+ +
+
+
+ +
+
+

Approved Today

+

+ {articleStats?.approved_today || 0} +

+
+
+
+ +
+
+
+ +
+
+

Approval Rate

+

+ {articleStats?.approval_percentage_today?.toFixed(1) || 0}% +

+
+
+
+
+
+ + {/* System Statistics */} +
+

System Overview

+
+
+
+
+ +
+
+

Active Feeds

+

+ {systemStats?.active_feeds || 0} + + /{systemStats?.total_feeds || 0} + +

+
+
+
+ +
+
+
+ +
+
+

Platform Accounts

+

+ {systemStats?.active_platform_accounts || 0} + + /{systemStats?.total_platform_accounts || 0} + +

+
+
+
+ +
+
+
+ +
+
+

Platform Channels

+

+ {systemStats?.active_platform_channels || 0} + + /{systemStats?.total_platform_channels || 0} + +

+
+
+
+ +
+
+
+ +
+
+

Active Routes

+

+ {systemStats?.active_routes || 0} + + /{systemStats?.total_routes || 0} + +

+
+
+
+
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/pages/Feeds.tsx b/frontend/src/pages/Feeds.tsx new file mode 100644 index 0000000..e468911 --- /dev/null +++ b/frontend/src/pages/Feeds.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Rss, Globe, ToggleLeft, ToggleRight, ExternalLink } from 'lucide-react'; +import { apiClient, type Feed } from '../lib/api'; + +const Feeds: React.FC = () => { + const queryClient = useQueryClient(); + + const { data: feeds, isLoading, error } = useQuery({ + queryKey: ['feeds'], + queryFn: () => apiClient.getFeeds(), + }); + + const toggleMutation = useMutation({ + mutationFn: (feedId: number) => apiClient.toggleFeed(feedId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['feeds'] }); + }, + }); + + const handleToggle = (feedId: number) => { + toggleMutation.mutate(feedId); + }; + + const getTypeIcon = (type: string) => { + switch (type) { + case 'rss': + return ; + case 'website': + return ; + default: + return ; + } + }; + + if (isLoading) { + return ( +
+
+
+
+ {[...Array(6)].map((_, i) => ( +
+
+
+
+
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+
+

Failed to load feeds

+
+
+ ); + } + + return ( +
+
+

Feeds

+

+ Manage your RSS feeds and website sources +

+
+ +
+ {feeds?.map((feed: Feed) => ( +
+
+
+ {getTypeIcon(feed.type)} +

+ {feed.name} +

+
+ +
+ +

+ {feed.description || 'No description provided'} +

+ +
+
+ + {feed.is_active ? 'Active' : 'Inactive'} + + + {feed.type.toUpperCase()} + +
+ + + +
+ +
+ Added {new Date(feed.created_at).toLocaleDateString()} +
+
+ ))} + + {feeds?.length === 0 && ( +
+ +

No feeds

+

+ Get started by adding your first feed. +

+
+ )} +
+
+ ); +}; + +export default Feeds; \ No newline at end of file diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..94952d1 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Settings as SettingsIcon, ToggleLeft, ToggleRight } from 'lucide-react'; +import { apiClient } from '../lib/api'; + +const Settings: React.FC = () => { + const queryClient = useQueryClient(); + + const { data: settings, isLoading, error } = useQuery({ + queryKey: ['settings'], + queryFn: () => apiClient.getSettings(), + }); + + const updateMutation = useMutation({ + mutationFn: (data: any) => apiClient.updateSettings(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings'] }); + }, + }); + + const handleToggle = (key: string, value: boolean) => { + updateMutation.mutate({ [key]: value }); + }; + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

Failed to load settings

+
+
+ ); + } + + return ( +
+
+

Settings

+

+ Configure your system preferences +

+
+ +
+ {/* Article Processing Settings */} +
+
+

+ + Article Processing +

+

+ Control how articles are processed and handled +

+
+
+
+
+

+ Article Processing Enabled +

+

+ Enable automatic fetching and processing of articles from feeds +

+
+ +
+ +
+
+

+ Publishing Approvals Required +

+

+ Require manual approval before articles are published to platforms +

+
+ +
+
+
+ + {/* Status indicator */} + {updateMutation.isPending && ( +
+
+
+

Updating settings...

+
+
+ )} + + {updateMutation.isError && ( +
+

Failed to update settings. Please try again.

+
+ )} + + {updateMutation.isSuccess && ( +
+

Settings updated successfully!

+
+ )} +
+
+ ); +}; + +export default Settings; \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..227a6c6 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3d1e067 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 5173, + watch: { + usePolling: true, + }, + }, +}) diff --git a/package.json b/package.json deleted file mode 100644 index 6519a6a..0000000 --- a/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.1.18", - "autoprefixer": "^10.4.20", - "laravel-vite-plugin": "^1.2.0", - "postcss": "^8.5.1", - "tailwindcss": "^4.0.0", - "vite": "^6.2.4" - }, - "dependencies": { - "alpinejs": "^3.14.8", - "axios": "^1.8.0" - } -} diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 8dc11a1..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; diff --git a/resources/css/app.css b/resources/css/app.css deleted file mode 100644 index d4b5078..0000000 --- a/resources/css/app.css +++ /dev/null @@ -1 +0,0 @@ -@import 'tailwindcss'; diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index a8093be..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1,7 +0,0 @@ -import './bootstrap'; - -import Alpine from 'alpinejs'; - -window.Alpine = Alpine; - -Alpine.start(); diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js deleted file mode 100644 index 5f1390b..0000000 --- a/resources/js/bootstrap.js +++ /dev/null @@ -1,4 +0,0 @@ -import axios from 'axios'; -window.axios = axios; - -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php deleted file mode 100644 index 3d38186..0000000 --- a/resources/views/auth/confirm-password.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - -
- {{ __('This is a secure area of the application. Please confirm your password before continuing.') }} -
- -
- @csrf - - -
- - - - - -
- -
- - {{ __('Confirm') }} - -
-
-
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php deleted file mode 100644 index cb32e08..0000000 --- a/resources/views/auth/forgot-password.blade.php +++ /dev/null @@ -1,25 +0,0 @@ - -
- {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} -
- - - - -
- @csrf - - -
- - - -
- -
- - {{ __('Email Password Reset Link') }} - -
-
-
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php deleted file mode 100644 index 78b684f..0000000 --- a/resources/views/auth/login.blade.php +++ /dev/null @@ -1,47 +0,0 @@ - - - - -
- @csrf - - -
- - - -
- - -
- - - - - -
- - -
- -
- -
- @if (Route::has('password.request')) - - {{ __('Forgot your password?') }} - - @endif - - - {{ __('Log in') }} - -
-
-
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php deleted file mode 100644 index a857242..0000000 --- a/resources/views/auth/register.blade.php +++ /dev/null @@ -1,52 +0,0 @@ - -
- @csrf - - -
- - - -
- - -
- - - -
- - -
- - - - - -
- - -
- - - - - -
- -
- - {{ __('Already registered?') }} - - - - {{ __('Register') }} - -
-
-
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php deleted file mode 100644 index a6494cc..0000000 --- a/resources/views/auth/reset-password.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - -
- @csrf - - - - - -
- - - -
- - -
- - - -
- - -
- - - - - -
- -
- - {{ __('Reset Password') }} - -
-
-
diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php deleted file mode 100644 index eaf811d..0000000 --- a/resources/views/auth/verify-email.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - -
- {{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }} -
- - @if (session('status') == 'verification-link-sent') -
- {{ __('A new verification link has been sent to the email address you provided during registration.') }} -
- @endif - -
-
- @csrf - -
- - {{ __('Resend Verification Email') }} - -
-
- -
- @csrf - - -
-
-
diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php deleted file mode 100644 index 46579cf..0000000 --- a/resources/views/components/application-logo.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php deleted file mode 100644 index c4bd6e2..0000000 --- a/resources/views/components/auth-session-status.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -@props(['status']) - -@if ($status) -
merge(['class' => 'font-medium text-sm text-green-600']) }}> - {{ $status }} -
-@endif diff --git a/resources/views/components/danger-button.blade.php b/resources/views/components/danger-button.blade.php deleted file mode 100644 index d17d288..0000000 --- a/resources/views/components/danger-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/dropdown-link.blade.php b/resources/views/components/dropdown-link.blade.php deleted file mode 100644 index e0f8ce1..0000000 --- a/resources/views/components/dropdown-link.blade.php +++ /dev/null @@ -1 +0,0 @@ -merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}>{{ $slot }} diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php deleted file mode 100644 index a46f7c8..0000000 --- a/resources/views/components/dropdown.blade.php +++ /dev/null @@ -1,35 +0,0 @@ -@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white']) - -@php -$alignmentClasses = match ($align) { - 'left' => 'ltr:origin-top-left rtl:origin-top-right start-0', - 'top' => 'origin-top', - default => 'ltr:origin-top-right rtl:origin-top-left end-0', -}; - -$width = match ($width) { - '48' => 'w-48', - default => $width, -}; -@endphp - -
-
- {{ $trigger }} -
- - -
diff --git a/resources/views/components/input-error.blade.php b/resources/views/components/input-error.blade.php deleted file mode 100644 index 9e6da21..0000000 --- a/resources/views/components/input-error.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props(['messages']) - -@if ($messages) -
    merge(['class' => 'text-sm text-red-600 space-y-1']) }}> - @foreach ((array) $messages as $message) -
  • {{ $message }}
  • - @endforeach -
-@endif diff --git a/resources/views/components/input-label.blade.php b/resources/views/components/input-label.blade.php deleted file mode 100644 index 1cc65e2..0000000 --- a/resources/views/components/input-label.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -@props(['value']) - - diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php deleted file mode 100644 index 70704c1..0000000 --- a/resources/views/components/modal.blade.php +++ /dev/null @@ -1,78 +0,0 @@ -@props([ - 'name', - 'show' => false, - 'maxWidth' => '2xl' -]) - -@php -$maxWidth = [ - 'sm' => 'sm:max-w-sm', - 'md' => 'sm:max-w-md', - 'lg' => 'sm:max-w-lg', - 'xl' => 'sm:max-w-xl', - '2xl' => 'sm:max-w-2xl', -][$maxWidth]; -@endphp - -
-
-
-
- -
- {{ $slot }} -
-
diff --git a/resources/views/components/nav-link.blade.php b/resources/views/components/nav-link.blade.php deleted file mode 100644 index 5c101a2..0000000 --- a/resources/views/components/nav-link.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -@props(['active']) - -@php -$classes = ($active ?? false) - ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' - : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out'; -@endphp - -merge(['class' => $classes]) }}> - {{ $slot }} - diff --git a/resources/views/components/primary-button.blade.php b/resources/views/components/primary-button.blade.php deleted file mode 100644 index d71f0b6..0000000 --- a/resources/views/components/primary-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/responsive-nav-link.blade.php b/resources/views/components/responsive-nav-link.blade.php deleted file mode 100644 index 43b91e7..0000000 --- a/resources/views/components/responsive-nav-link.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -@props(['active']) - -@php -$classes = ($active ?? false) - ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out' - : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out'; -@endphp - -merge(['class' => $classes]) }}> - {{ $slot }} - diff --git a/resources/views/components/secondary-button.blade.php b/resources/views/components/secondary-button.blade.php deleted file mode 100644 index b32b69f..0000000 --- a/resources/views/components/secondary-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/text-input.blade.php b/resources/views/components/text-input.blade.php deleted file mode 100644 index da1b12d..0000000 --- a/resources/views/components/text-input.blade.php +++ /dev/null @@ -1,3 +0,0 @@ -@props(['disabled' => false]) - -merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) }}> diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php deleted file mode 100644 index 66028f2..0000000 --- a/resources/views/dashboard.blade.php +++ /dev/null @@ -1,17 +0,0 @@ - - -

- {{ __('Dashboard') }} -

-
- -
-
-
-
- {{ __("You're logged in!") }} -
-
-
-
-
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php deleted file mode 100644 index 8849a7d..0000000 --- a/resources/views/layouts/app.blade.php +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - {{ config('app.name', 'FFR') }} - - - - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles - - -
- -
- - -
-
-

FFR

- -
- -
- - - - - -
- -
- -
-

FFR

-
-
- -
- {{ $slot }} -
-
-
- - @livewireScripts - - diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php deleted file mode 100644 index 3628119..0000000 --- a/resources/views/layouts/guest.blade.php +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles - - - {{ $slot }} - @livewireScripts - - diff --git a/resources/views/layouts/navigation-items.blade.php b/resources/views/layouts/navigation-items.blade.php deleted file mode 100644 index f98d21b..0000000 --- a/resources/views/layouts/navigation-items.blade.php +++ /dev/null @@ -1,53 +0,0 @@ -@php -$navigation = [ - ['name' => 'Dashboard', 'route' => 'dashboard', 'icon' => 'home'], - ['name' => 'Articles', 'route' => 'articles', 'icon' => 'document-text'], - ['name' => 'Feeds', 'route' => 'feeds', 'icon' => 'rss'], - ['name' => 'Channels', 'route' => 'channels', 'icon' => 'hashtag'], - ['name' => 'Routes', 'route' => 'routes', 'icon' => 'arrow-path'], - ['name' => 'Settings', 'route' => 'settings', 'icon' => 'cog-6-tooth'], -]; -@endphp - -@foreach ($navigation as $item) - - @switch($item['icon']) - @case('home') - - - - @break - @case('document-text') - - - - @break - @case('rss') - - - - @break - @case('hashtag') - - - - @break - @case('arrow-path') - - - - @break - @case('cog-6-tooth') - - - - - @break - @endswitch - {{ $item['name'] }} - -@endforeach diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php deleted file mode 100644 index c2d3a65..0000000 --- a/resources/views/layouts/navigation.blade.php +++ /dev/null @@ -1,100 +0,0 @@ - diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php deleted file mode 100644 index 62411ef..0000000 --- a/resources/views/livewire/articles.blade.php +++ /dev/null @@ -1,139 +0,0 @@ -
-
-
-

Articles

-

- Manage and review articles from your feeds -

- @if ($approvalsEnabled) -
- - - - - Approval system enabled -
- @endif -
- -
- -
- @forelse ($articles as $article) -
-
-
-

- {{ $article->title ?? 'Untitled Article' }} -

-

- {{ $article->description ?? 'No description available' }} -

-
- Feed: {{ $article->feed?->name ?? 'Unknown' }} - - {{ $article->created_at->format('M d, Y') }} -
-
-
- @if ($article->is_published) - - - - - Published - - @elseif ($article->approval_status === 'approved') - - - - - Approved - - @elseif ($article->approval_status === 'rejected') - - - - - Rejected - - @else - - - - - Pending - - @endif - - @if ($article->url) - - - - - - @endif -
-
- - @if ($article->approval_status === 'pending' && $approvalsEnabled) -
- - -
- @endif -
- @empty -
- - - -

No articles

-

- No articles have been fetched yet. -

-
- @endforelse - - @if ($articles->hasPages()) -
- {{ $articles->links() }} -
- @endif -
-
diff --git a/resources/views/livewire/channels.blade.php b/resources/views/livewire/channels.blade.php deleted file mode 100644 index ec4691e..0000000 --- a/resources/views/livewire/channels.blade.php +++ /dev/null @@ -1,163 +0,0 @@ -
-
-

Channels

-

- Manage your platform channels and linked accounts -

-
- -
- @forelse ($channels as $channel) -
-
-
-
- - - -
-
-

- {{ $channel->display_name ?? $channel->name }} -

- @if ($channel->platformInstance) - - {{ $channel->platformInstance->name }} - - @endif -
-
- -
- - @if ($channel->description) -

- {{ $channel->description }} -

- @endif - -
-
- - Linked Accounts ({{ $channel->platformAccounts->count() }}) - - -
- - @if ($channel->platformAccounts->isNotEmpty()) -
- @foreach ($channel->platformAccounts->take(3) as $account) -
- {{ $account->username }} - -
- @endforeach - @if ($channel->platformAccounts->count() > 3) - +{{ $channel->platformAccounts->count() - 3 }} more - @endif -
- @else -

No accounts linked

- @endif -
- -
- - {{ $channel->is_active ? 'Active' : 'Inactive' }} - - - {{ $channel->created_at->format('M d, Y') }} - -
-
- @empty -
- - - -

No channels

-

- No platform channels have been configured yet. -

-
- @endforelse -
- - - @if ($managingChannel) - - @endif -
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php deleted file mode 100644 index 9060411..0000000 --- a/resources/views/livewire/dashboard.blade.php +++ /dev/null @@ -1,153 +0,0 @@ -
-
-

Dashboard

-

- Overview of your feed management system -

-
- - -
-

System Overview

-
- -
-
-
- - - -
-
-

Active Feeds

-

- {{ $systemStats['active_feeds'] }} - /{{ $systemStats['total_feeds'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Platform Accounts

-

- {{ $systemStats['active_platform_accounts'] }} - /{{ $systemStats['total_platform_accounts'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Platform Channels

-

- {{ $systemStats['active_platform_channels'] }} - /{{ $systemStats['total_platform_channels'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Active Routes

-

- {{ $systemStats['active_routes'] }} - /{{ $systemStats['total_routes'] }} -

-
-
-
-
-
- - -
-
-

Article Statistics

- -
-
- -
-
-
- - - -
-
-

Articles Fetched

-

- {{ $articleStats['articles_fetched'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Articles Published

-

- {{ $articleStats['articles_published'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Published Rate

-

- {{ $articleStats['published_percentage'] }}% -

-
-
-
-
-
-
diff --git a/resources/views/livewire/feeds.blade.php b/resources/views/livewire/feeds.blade.php deleted file mode 100644 index 1a2e5f0..0000000 --- a/resources/views/livewire/feeds.blade.php +++ /dev/null @@ -1,87 +0,0 @@ -
-
-

Feeds

-

- Manage your news feed sources -

-
- -
- @forelse ($feeds as $feed) -
-
-
-
- @if ($feed->type === 'rss') - - - - @else - - - - @endif -
-
-

- {{ $feed->name }} -

- @if ($feed->description) -

- {{ $feed->description }} -

- @endif -
-
- -
- -
-
- - {{ $feed->is_active ? 'Active' : 'Inactive' }} - - - {{ strtoupper($feed->type) }} - -
- @if ($feed->url) - - - - - - @endif -
- -
- Created {{ $feed->created_at->format('M d, Y') }} -
-
- @empty -
- - - -

No feeds

-

- No feeds have been configured yet. -

-
- @endforelse -
-
diff --git a/resources/views/livewire/onboarding.blade.php b/resources/views/livewire/onboarding.blade.php deleted file mode 100644 index bba2156..0000000 --- a/resources/views/livewire/onboarding.blade.php +++ /dev/null @@ -1,546 +0,0 @@ -
-
- - {{-- Step 1: Welcome --}} - @if ($step === 1) -
-

Welcome to FFR

-

- Let's get you set up! We'll help you configure your Lemmy account, add your first feed, and create a channel for posting. -

- -
-
-
1
- Connect your Lemmy account -
-
-
2
- Add your first feed -
-
-
3
- Configure a channel -
-
-
4
- Create a route -
-
-
5
- You're ready to go! -
-
- -
- -
-
- @endif - - {{-- Step 2: Platform Account --}} - @if ($step === 2) -
-

Connect Your Lemmy Account

-

- {{ $existingAccount ? 'Your connected Lemmy account' : 'Enter your Lemmy instance details and login credentials' }} -

- - {{-- Progress indicator --}} -
-
1
-
2
-
3
-
4
-
- - @if (!empty($errors['general'])) -
-

{{ $errors['general'] }}

-
- @endif - - @if ($existingAccount) - {{-- Account Card --}} -
-
-
-
-
- - - -
-
-

Account Connected

-
-

Username: {{ $existingAccount['username'] }}

-

Instance: {{ str_replace('https://', '', $existingAccount['instance_url']) }}

-
-
-
- -
-
- -
- - -
-
- @else - {{-- Login Form --}} -
-
- - -

Enter just the domain name (e.g., lemmy.world, belgae.social)

- @error('instanceUrl')

{{ $message }}

@enderror -
- -
- - - @error('username')

{{ $message }}

@enderror -
- -
- - - @error('password')

{{ $message }}

@enderror -
- -
- - -
-
- @endif -
- @endif - - {{-- Step 3: Feed --}} - @if ($step === 3) -
-

Add Your First Feed

-

- Choose from our supported news providers to monitor for new articles -

- - {{-- Progress indicator --}} -
-
-
2
-
3
-
4
-
- -
- @if (!empty($errors['general'])) -
-

{{ $errors['general'] }}

-
- @endif - -
- - - @error('feedName')

{{ $message }}

@enderror -
- -
- - - @error('feedProvider')

{{ $message }}

@enderror -
- -
- - - @error('feedLanguageId')

{{ $message }}

@enderror -
- -
- - - @error('feedDescription')

{{ $message }}

@enderror -
- -
- - -
-
-
- @endif - - {{-- Step 4: Channel --}} - @if ($step === 4) -
-

Configure Your Channel

-

- Set up a Lemmy community where articles will be posted -

- - {{-- Progress indicator --}} -
-
-
-
3
-
4
-
- -
- @if (!empty($errors['general'])) -
-

{{ $errors['general'] }}

-
- @endif - -
- - -

Enter the community name (without the @ or instance)

- @error('channelName')

{{ $message }}

@enderror -
- -
- - - @error('platformInstanceId')

{{ $message }}

@enderror -
- -
- - - @error('channelLanguageId')

{{ $message }}

@enderror -
- -
- - - @error('channelDescription')

{{ $message }}

@enderror -
- -
- - -
-
-
- @endif - - {{-- Step 5: Route --}} - @if ($step === 5) -
-

Create Your First Route

-

- Connect your feed to a channel by creating a route. This tells FFR which articles to post where. -

- - {{-- Progress indicator --}} -
-
-
-
-
4
-
5
-
- -
- @if (!empty($errors['general'])) -
-

{{ $errors['general'] }}

-
- @endif - -
- - - @error('routeFeedId')

{{ $message }}

@enderror -
- -
- - - @if ($channels->isEmpty()) -

- No channels available. Please create a channel first. -

- @endif - @error('routeChannelId')

{{ $message }}

@enderror -
- -
- - -

- Higher priority routes are processed first (default: 50) -

- @error('routePriority')

{{ $message }}

@enderror -
- -
- - -
-
-
- @endif - - {{-- Step 6: Complete --}} - @if ($step === 6) -
-
-
- - - -
-

Setup Complete!

-

- Great! You've successfully configured FFR. Your feeds will now be monitored and articles will be automatically posted to your configured channels. -

-
- - {{-- Progress indicator --}} -
-
-
-
-
-
- -
-
-

What happens next?

-
    -
  • • Your feeds will be checked regularly for new articles
  • -
  • • New articles will be automatically posted to your channels
  • -
  • • You can monitor activity in the Articles and other sections
  • -
-
- -
-

Want more control?

-

- You can add more feeds, channels, and configure settings from the dashboard. -

-
-
- -
- - -
- View Articles - • - Manage Feeds - • - Settings -
-
-
- @endif -
-
diff --git a/resources/views/livewire/routes.blade.php b/resources/views/livewire/routes.blade.php deleted file mode 100644 index 364bbfa..0000000 --- a/resources/views/livewire/routes.blade.php +++ /dev/null @@ -1,380 +0,0 @@ -
-
-
-

Routes

-

- Manage connections between your feeds and channels -

-
- -
- -
- @forelse ($routes as $route) -
-
-
-
-

- {{ $route->feed?->name }} → {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }} -

- @if ($route->is_active) - - - - - Active - - @else - - - - - Inactive - - @endif -
-
- Priority: {{ $route->priority }} - - Feed: {{ $route->feed?->name }} - - Channel: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }} - - Created: {{ $route->created_at->format('M d, Y') }} -
- @if ($route->platformChannel?->description) -

- {{ $route->platformChannel->description }} -

- @endif - @if ($route->keywords->isNotEmpty()) -
-
- - - - - Keywords -
-
- @foreach ($route->keywords as $keyword) - - {{ $keyword->keyword }} - - @endforeach -
-
- @else -
- No keyword filters - matches all articles -
- @endif -
-
- - - -
-
-
- @empty -
- - - -

No routes

-

- Get started by creating a new route to connect feeds with channels. -

-
- -
-
- @endforelse -
- - - @if ($showCreateModal) - - @endif - - - @if ($editingRoute) - - @endif -
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php deleted file mode 100644 index 96b050c..0000000 --- a/resources/views/livewire/settings.blade.php +++ /dev/null @@ -1,90 +0,0 @@ -
-
-

Settings

-

- Configure your system preferences -

-
- -
- -
-
-

- - - - - Article Processing -

-

- Control how articles are processed and handled -

-
-
-
-
-

- Article Processing Enabled -

-

- Enable automatic fetching and processing of articles from feeds -

-
- -
- -
-
-

- Publishing Approvals Required -

-

- Require manual approval before articles are published to platforms -

-
- -
-
-
- - - @if ($successMessage) -
-

{{ $successMessage }}

-
- @endif - - @if ($errorMessage) -
-

{{ $errorMessage }}

-
- @endif -
-
diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php deleted file mode 100644 index e0e1d38..0000000 --- a/resources/views/profile/edit.blade.php +++ /dev/null @@ -1,29 +0,0 @@ - - -

- {{ __('Profile') }} -

-
- -
-
-
-
- @include('profile.partials.update-profile-information-form') -
-
- -
-
- @include('profile.partials.update-password-form') -
-
- -
-
- @include('profile.partials.delete-user-form') -
-
-
-
-
diff --git a/resources/views/profile/partials/delete-user-form.blade.php b/resources/views/profile/partials/delete-user-form.blade.php deleted file mode 100644 index edeeb4a..0000000 --- a/resources/views/profile/partials/delete-user-form.blade.php +++ /dev/null @@ -1,55 +0,0 @@ -
-
-

- {{ __('Delete Account') }} -

- -

- {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }} -

-
- - {{ __('Delete Account') }} - - -
- @csrf - @method('delete') - -

- {{ __('Are you sure you want to delete your account?') }} -

- -

- {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} -

- -
- - - - - -
- -
- - {{ __('Cancel') }} - - - - {{ __('Delete Account') }} - -
-
-
-
diff --git a/resources/views/profile/partials/update-password-form.blade.php b/resources/views/profile/partials/update-password-form.blade.php deleted file mode 100644 index eaca1ac..0000000 --- a/resources/views/profile/partials/update-password-form.blade.php +++ /dev/null @@ -1,48 +0,0 @@ -
-
-

- {{ __('Update Password') }} -

- -

- {{ __('Ensure your account is using a long, random password to stay secure.') }} -

-
- -
- @csrf - @method('put') - -
- - - -
- -
- - - -
- -
- - - -
- -
- {{ __('Save') }} - - @if (session('status') === 'password-updated') -

{{ __('Saved.') }}

- @endif -
-
-
diff --git a/resources/views/profile/partials/update-profile-information-form.blade.php b/resources/views/profile/partials/update-profile-information-form.blade.php deleted file mode 100644 index 5ae3d35..0000000 --- a/resources/views/profile/partials/update-profile-information-form.blade.php +++ /dev/null @@ -1,64 +0,0 @@ -
-
-

- {{ __('Profile Information') }} -

- -

- {{ __("Update your account's profile information and email address.") }} -

-
- -
- @csrf -
- -
- @csrf - @method('patch') - -
- - - -
- -
- - - - - @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) -
-

- {{ __('Your email address is unverified.') }} - - -

- - @if (session('status') === 'verification-link-sent') -

- {{ __('A new verification link has been sent to your email address.') }} -

- @endif -
- @endif -
- -
- {{ __('Save') }} - - @if (session('status') === 'profile-updated') -

{{ __('Saved.') }}

- @endif -
-
-
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index 3fdc575..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,9 +0,0 @@ - - - - FFR - - -

Welcome

- - diff --git a/routes/auth.php b/routes/auth.php deleted file mode 100644 index 3926ecf..0000000 --- a/routes/auth.php +++ /dev/null @@ -1,59 +0,0 @@ -group(function () { - Route::get('register', [RegisteredUserController::class, 'create']) - ->name('register'); - - Route::post('register', [RegisteredUserController::class, 'store']); - - Route::get('login', [AuthenticatedSessionController::class, 'create']) - ->name('login'); - - Route::post('login', [AuthenticatedSessionController::class, 'store']); - - Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) - ->name('password.request'); - - Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) - ->name('password.email'); - - Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) - ->name('password.reset'); - - Route::post('reset-password', [NewPasswordController::class, 'store']) - ->name('password.store'); -}); - -Route::middleware('auth')->group(function () { - Route::get('verify-email', EmailVerificationPromptController::class) - ->name('verification.notice'); - - Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) - ->middleware(['signed', 'throttle:6,1']) - ->name('verification.verify'); - - Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) - ->middleware('throttle:6,1') - ->name('verification.send'); - - Route::get('confirm-password', [ConfirmablePasswordController::class, 'show']) - ->name('password.confirm'); - - Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']); - - Route::put('password', [PasswordController::class, 'update'])->name('password.update'); - - Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) - ->name('logout'); -}); diff --git a/routes/console.php b/routes/console.php deleted file mode 100644 index 83db0a3..0000000 --- a/routes/console.php +++ /dev/null @@ -1,22 +0,0 @@ -everyTenMinutes()->name('sync-lemmy-channel-posts'); - -Schedule::job(new PublishNextArticleJob) - ->everyFiveMinutes() - ->name('publish-next-article') - ->withoutOverlapping() - ->onOneServer(); - -Schedule::job(new ArticleDiscoveryJob) - ->everyThirtyMinutes() - ->name('refresh-articles') - ->withoutOverlapping() - ->onOneServer(); diff --git a/routes/web.php b/routes/web.php deleted file mode 100644 index b7fa3da..0000000 --- a/routes/web.php +++ /dev/null @@ -1,40 +0,0 @@ -route('dashboard'); -}); - -// Onboarding routes (protected by auth, but need incomplete onboarding) -Route::middleware(['auth', 'onboarding.incomplete'])->group(function () { - Route::get('/onboarding', Onboarding::class)->name('onboarding'); -}); - -// Main app routes (protected by auth and require completed onboarding) -Route::middleware(['auth', 'onboarding.complete'])->group(function () { - Route::get('/dashboard', Dashboard::class)->name('dashboard'); - Route::get('/articles', Articles::class)->name('articles'); - Route::get('/feeds', Feeds::class)->name('feeds'); - Route::get('/channels', Channels::class)->name('channels'); - Route::get('/routes', Routes::class)->name('routes'); - Route::get('/settings', Settings::class)->name('settings'); -}); - -// Profile routes (auth protected, no onboarding check needed) -Route::middleware('auth')->group(function () { - Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); - Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); - Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); -}); - -require __DIR__.'/auth.php'; diff --git a/shell.nix b/shell.nix deleted file mode 100644 index e14efc4..0000000 --- a/shell.nix +++ /dev/null @@ -1,136 +0,0 @@ -{ pkgs ? import {} }: - -pkgs.mkShell { - buildInputs = with pkgs; [ - # PHP and tools - php83 - php83Packages.composer - - # Node.js and npm - nodejs_22 - - # Container tools - podman - podman-compose - - # Database client (for direct DB access) - mariadb.client - - # Redis client - redis - - # Utilities - git - curl - gnumake - ]; - - shellHook = '' - export USER_ID=$(id -u) - export GROUP_ID=$(id -g) - export PODMAN_USERNS=keep-id - - # Compose file location - COMPOSE_FILE="$PWD/docker/dev/docker-compose.yml" - - # =================== - # ALIASES - # =================== - alias pc='podman-compose -f $COMPOSE_FILE' - - # =================== - # DEV COMMANDS - # =================== - dev-up() { - echo "Starting services..." - PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@" - echo "" - podman-compose -f $COMPOSE_FILE ps - echo "" - echo "App available at: http://localhost:8000" - } - - dev-down() { - if [[ "$1" == "-v" ]]; then - echo "Stopping services and removing volumes..." - podman-compose -f $COMPOSE_FILE down -v - else - echo "Stopping services..." - podman-compose -f $COMPOSE_FILE down - fi - } - - dev-restart() { - echo "Restarting services..." - podman-compose -f $COMPOSE_FILE restart "$@" - } - - dev-logs() { - podman-compose -f $COMPOSE_FILE logs -f app "$@" - } - - dev-logs-db() { - podman-compose -f $COMPOSE_FILE logs -f db "$@" - } - - dev-shell() { - podman-compose -f $COMPOSE_FILE exec app sh - } - - dev-artisan() { - podman-compose -f $COMPOSE_FILE exec app php artisan "$@" - } - - # =================== - # PROD COMMANDS - # =================== - prod-build() { - local tag="''${1:-latest}" - local image="codeberg.org/lvl0/ffr:$tag" - - echo "Building production image: $image" - if ! podman build -t "$image" -f Dockerfile .; then - echo "Build failed!" - return 1 - fi - - echo "" - echo "Pushing to registry..." - if ! podman push "$image"; then - echo "" - echo "Push failed! You may need to login first:" - echo " podman login codeberg.org" - return 1 - fi - - echo "" - echo "Done! Image pushed: $image" - } - - # =================== - # WELCOME MESSAGE - # =================== - echo "" - echo "╔═══════════════════════════════════════════════════════════╗" - echo "║ FFR Dev Environment ║" - echo "╚═══════════════════════════════════════════════════════════╝" - echo "" - echo " Podman: $(podman --version | cut -d' ' -f3)" - echo "" - echo "Commands:" - echo " dev-up [services] Start all or specific services" - echo " dev-down [-v] Stop services (-v removes volumes)" - echo " dev-restart Restart services" - echo " dev-logs Tail app logs" - echo " dev-logs-db Tail database logs" - echo " dev-shell Shell into app container" - echo " dev-artisan Run artisan command" - echo " prod-build [tag] Build and push prod image (default: latest)" - echo "" - echo "Services:" - echo " app Laravel + Vite http://localhost:8000" - echo " db MariaDB localhost:3307" - echo " redis Redis localhost:6380" - echo "" - ''; -} diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 94ed352..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - './resources/**/*.blade.php', - './resources/**/*.js', - './app/Livewire/**/*.php', - './vendor/livewire/livewire/dist/livewire.esm.js', - './storage/framework/views/*.php', - ], - theme: { - extend: {}, - }, - plugins: [], -}; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php deleted file mode 100644 index 13dcb7c..0000000 --- a/tests/Feature/Auth/AuthenticationTest.php +++ /dev/null @@ -1,54 +0,0 @@ -get('/login'); - - $response->assertStatus(200); - } - - public function test_users_can_authenticate_using_the_login_screen(): void - { - $user = User::factory()->create(); - - $response = $this->post('/login', [ - 'email' => $user->email, - 'password' => 'password', - ]); - - $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); - } - - public function test_users_can_not_authenticate_with_invalid_password(): void - { - $user = User::factory()->create(); - - $this->post('/login', [ - 'email' => $user->email, - 'password' => 'wrong-password', - ]); - - $this->assertGuest(); - } - - public function test_users_can_logout(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/logout'); - - $this->assertGuest(); - $response->assertRedirect('/'); - } -} diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php deleted file mode 100644 index 705570b..0000000 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ /dev/null @@ -1,58 +0,0 @@ -unverified()->create(); - - $response = $this->actingAs($user)->get('/verify-email'); - - $response->assertStatus(200); - } - - public function test_email_can_be_verified(): void - { - $user = User::factory()->unverified()->create(); - - Event::fake(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1($user->email)] - ); - - $response = $this->actingAs($user)->get($verificationUrl); - - Event::assertDispatched(Verified::class); - $this->assertTrue($user->fresh()->hasVerifiedEmail()); - $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); - } - - public function test_email_is_not_verified_with_invalid_hash(): void - { - $user = User::factory()->unverified()->create(); - - $verificationUrl = URL::temporarySignedRoute( - 'verification.verify', - now()->addMinutes(60), - ['id' => $user->id, 'hash' => sha1('wrong-email')] - ); - - $this->actingAs($user)->get($verificationUrl); - - $this->assertFalse($user->fresh()->hasVerifiedEmail()); - } -} diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php deleted file mode 100644 index ff85721..0000000 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ /dev/null @@ -1,44 +0,0 @@ -create(); - - $response = $this->actingAs($user)->get('/confirm-password'); - - $response->assertStatus(200); - } - - public function test_password_can_be_confirmed(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/confirm-password', [ - 'password' => 'password', - ]); - - $response->assertRedirect(); - $response->assertSessionHasNoErrors(); - } - - public function test_password_is_not_confirmed_with_invalid_password(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/confirm-password', [ - 'password' => 'wrong-password', - ]); - - $response->assertSessionHasErrors(); - } -} diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php deleted file mode 100644 index aa50350..0000000 --- a/tests/Feature/Auth/PasswordResetTest.php +++ /dev/null @@ -1,73 +0,0 @@ -get('/forgot-password'); - - $response->assertStatus(200); - } - - public function test_reset_password_link_can_be_requested(): void - { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post('/forgot-password', ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class); - } - - public function test_reset_password_screen_can_be_rendered(): void - { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post('/forgot-password', ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get('/reset-password/'.$notification->token); - - $response->assertStatus(200); - - return true; - }); - } - - public function test_password_can_be_reset_with_valid_token(): void - { - Notification::fake(); - - $user = User::factory()->create(); - - $this->post('/forgot-password', ['email' => $user->email]); - - Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->post('/reset-password', [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('login')); - - return true; - }); - } -} diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Auth/PasswordUpdateTest.php deleted file mode 100644 index ca28c6c..0000000 --- a/tests/Feature/Auth/PasswordUpdateTest.php +++ /dev/null @@ -1,51 +0,0 @@ -create(); - - $response = $this - ->actingAs($user) - ->from('/profile') - ->put('/password', [ - 'current_password' => 'password', - 'password' => 'new-password', - 'password_confirmation' => 'new-password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); - - $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); - } - - public function test_correct_password_must_be_provided_to_update_password(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->from('/profile') - ->put('/password', [ - 'current_password' => 'wrong-password', - 'password' => 'new-password', - 'password_confirmation' => 'new-password', - ]); - - $response - ->assertSessionHasErrorsIn('updatePassword', 'current_password') - ->assertRedirect('/profile'); - } -} diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php deleted file mode 100644 index 1489d0e..0000000 --- a/tests/Feature/Auth/RegistrationTest.php +++ /dev/null @@ -1,31 +0,0 @@ -get('/register'); - - $response->assertStatus(200); - } - - public function test_new_users_can_register(): void - { - $response = $this->post('/register', [ - 'name' => 'Test User', - 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - $this->assertAuthenticated(); - $response->assertRedirect(route('dashboard', absolute: false)); - } -} diff --git a/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php b/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php deleted file mode 100644 index dc1b71f..0000000 --- a/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php +++ /dev/null @@ -1,55 +0,0 @@ -artisan('channel:sync unsupported'); - - // Assert - $exitCode->assertFailed(); - $exitCode->expectsOutput('Unsupported platform: unsupported'); - } - - public function test_command_returns_failure_exit_code_for_unsupported_platform(): void - { - // Act - /** @var PendingCommand $exitCode */ - $exitCode = $this->artisan('channel:sync invalid'); - - // Assert - $exitCode->assertExitCode(1); - } - - public function test_command_accepts_lemmy_platform_argument(): void - { - // Act - Test that the command accepts lemmy as a valid platform argument - $exitCode = $this->artisan('channel:sync lemmy'); - - // Assert - Command should succeed (not fail with argument validation error) - $exitCode->assertSuccessful(); - $exitCode->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels'); - } - - public function test_command_handles_default_platform(): void - { - // Act - Test that the command works with default platform (should be lemmy) - $exitCode = $this->artisan('channel:sync'); - - // Assert - Command should succeed with default platform - $exitCode->assertSuccessful(); - $exitCode->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels'); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php deleted file mode 100644 index 3934a15..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php +++ /dev/null @@ -1,189 +0,0 @@ -feed = Feed::factory()->create(); - $this->channel = PlatformChannel::factory()->create(); - - $this->route = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - } - - public function test_can_get_keywords_for_route(): void - { - $keyword = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'keyword' => 'test keyword', - 'is_active' => true - ]); - - $response = $this->getJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords"); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - '*' => [ - 'id', - 'keyword', - 'is_active', - 'feed_id', - 'platform_channel_id' - ] - ] - ]) - ->assertJsonPath('data.0.keyword', 'test keyword'); - } - - public function test_can_create_keyword_for_route(): void - { - $keywordData = [ - 'keyword' => 'new keyword', - 'is_active' => true - ]; - - $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", $keywordData); - - $response->assertStatus(201) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'id', - 'keyword', - 'is_active', - 'feed_id', - 'platform_channel_id' - ] - ]) - ->assertJsonPath('data.keyword', 'new keyword') - ->assertJsonPath('data.is_active', true); - - $this->assertDatabaseHas('keywords', [ - 'keyword' => 'new keyword', - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'is_active' => true - ]); - } - - public function test_cannot_create_duplicate_keyword_for_route(): void - { - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'keyword' => 'duplicate keyword' - ]); - - $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", [ - 'keyword' => 'duplicate keyword' - ]); - - $response->assertStatus(409) - ->assertJsonPath('success', false) - ->assertJsonPath('message', 'Keyword already exists for this route.'); - } - - public function test_can_update_keyword(): void - { - $keyword = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'is_active' => true - ]); - - $response = $this->putJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}", [ - 'is_active' => false - ]); - - $response->assertStatus(200) - ->assertJsonPath('data.is_active', false); - - $this->assertDatabaseHas('keywords', [ - 'id' => $keyword->id, - 'is_active' => false - ]); - } - - public function test_can_delete_keyword(): void - { - $keyword = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id - ]); - - $response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}"); - - $response->assertStatus(200); - - $this->assertDatabaseMissing('keywords', [ - 'id' => $keyword->id - ]); - } - - public function test_can_toggle_keyword(): void - { - $keyword = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'is_active' => true - ]); - - $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}/toggle"); - - $response->assertStatus(200) - ->assertJsonPath('data.is_active', false); - - $this->assertDatabaseHas('keywords', [ - 'id' => $keyword->id, - 'is_active' => false - ]); - } - - public function test_cannot_access_keyword_from_different_route(): void - { - $otherFeed = Feed::factory()->create(); - $otherChannel = PlatformChannel::factory()->create(); - - $keyword = Keyword::factory()->create([ - 'feed_id' => $otherFeed->id, - 'platform_channel_id' => $otherChannel->id - ]); - - $response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}"); - - $response->assertStatus(404) - ->assertJsonPath('message', 'Keyword not found for this route.'); - } - - public function test_validates_required_fields(): void - { - $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['keyword']); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php deleted file mode 100644 index dfca1c1..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php +++ /dev/null @@ -1,489 +0,0 @@ -create([ - 'id' => 1, - 'short_code' => 'en', - 'name' => 'English', - 'native_name' => 'English', - 'is_active' => true, - ]); - } - - public function test_status_shows_needs_onboarding_when_no_components_exist() - { - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => true, - 'current_step' => 'platform', - 'has_platform_account' => false, - 'has_feed' => false, - 'has_channel' => false, - 'has_route' => false, - 'onboarding_skipped' => false, - ], - ]); - } - - public function test_status_shows_feed_step_when_platform_account_exists() - { - PlatformAccount::factory()->create(['is_active' => true]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => true, - 'current_step' => 'feed', - 'has_platform_account' => true, - 'has_feed' => false, - 'has_channel' => false, - 'has_route' => false, - ], - ]); - } - - public function test_status_shows_channel_step_when_platform_account_and_feed_exist() - { - $language = Language::first(); - PlatformAccount::factory()->create(['is_active' => true]); - Feed::factory()->language($language)->create(['is_active' => true]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => true, - 'current_step' => 'channel', - 'has_platform_account' => true, - 'has_feed' => true, - 'has_channel' => false, - 'has_route' => false, - ], - ]); - } - - public function test_status_shows_route_step_when_platform_account_feed_and_channel_exist() - { - $language = Language::first(); - PlatformAccount::factory()->create(['is_active' => true]); - Feed::factory()->language($language)->create(['is_active' => true]); - PlatformChannel::factory()->create(['is_active' => true]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => true, - 'current_step' => 'route', - 'has_platform_account' => true, - 'has_feed' => true, - 'has_channel' => true, - 'has_route' => false, - ], - ]); - } - - public function test_status_shows_no_onboarding_needed_when_all_components_exist() - { - $language = Language::first(); - PlatformAccount::factory()->create(['is_active' => true]); - Feed::factory()->language($language)->create(['is_active' => true]); - PlatformChannel::factory()->create(['is_active' => true]); - Route::factory()->create(['is_active' => true]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => false, - 'current_step' => null, - 'has_platform_account' => true, - 'has_feed' => true, - 'has_channel' => true, - 'has_route' => true, - ], - ]); - } - - public function test_status_shows_no_onboarding_needed_when_skipped() - { - // No components exist but onboarding is skipped - Setting::create([ - 'key' => 'onboarding_skipped', - 'value' => 'true', - ]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => false, - 'current_step' => null, - 'has_platform_account' => false, - 'has_feed' => false, - 'has_channel' => false, - 'has_route' => false, - 'onboarding_skipped' => true, - ], - ]); - } - - public function test_options_returns_languages_and_platform_instances() - { - PlatformInstance::factory()->create([ - 'platform' => 'lemmy', - 'url' => 'https://lemmy.world', - 'name' => 'Lemmy World', - 'is_active' => true, - ]); - - $response = $this->getJson('/api/v1/onboarding/options'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'languages' => [ - '*' => ['id', 'short_code', 'name', 'native_name', 'is_active'] - ], - 'platform_instances' => [ - '*' => ['id', 'platform', 'url', 'name', 'description', 'is_active'] - ] - ] - ]); - } - - public function test_create_feed_validates_required_fields() - { - $response = $this->postJson('/api/v1/onboarding/feed', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['name', 'provider', 'language_id']); - } - - public function test_create_feed_creates_vrt_feed_successfully() - { - $feedData = [ - 'name' => 'VRT Test Feed', - 'provider' => 'vrt', - 'language_id' => 1, - 'description' => 'Test description', - ]; - - $response = $this->postJson('/api/v1/onboarding/feed', $feedData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'name' => 'VRT Test Feed', - 'url' => 'https://www.vrt.be/vrtnws/en/', - 'type' => 'website', - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'name' => 'VRT Test Feed', - 'url' => 'https://www.vrt.be/vrtnws/en/', - 'type' => 'website', - 'language_id' => 1, - 'is_active' => true, - ]); - } - - public function test_create_feed_creates_belga_feed_successfully() - { - $feedData = [ - 'name' => 'Belga Test Feed', - 'provider' => 'belga', - 'language_id' => 1, - 'description' => 'Test description', - ]; - - $response = $this->postJson('/api/v1/onboarding/feed', $feedData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'name' => 'Belga Test Feed', - 'url' => 'https://www.belganewsagency.eu/', - 'type' => 'website', - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'name' => 'Belga Test Feed', - 'url' => 'https://www.belganewsagency.eu/', - 'type' => 'website', - 'language_id' => 1, - 'is_active' => true, - ]); - } - - public function test_create_feed_rejects_invalid_provider() - { - $feedData = [ - 'name' => 'Invalid Feed', - 'provider' => 'invalid', - 'language_id' => 1, - 'description' => 'Test description', - ]; - - $response = $this->postJson('/api/v1/onboarding/feed', $feedData); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['provider']); - } - - public function test_create_channel_validates_required_fields() - { - $response = $this->postJson('/api/v1/onboarding/channel', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['name', 'platform_instance_id', 'language_id']); - } - - public function test_create_channel_creates_channel_successfully() - { - $platformInstance = PlatformInstance::factory()->create(); - $language = Language::factory()->create(); - - // Create a platform account for this instance first - PlatformAccount::factory()->create([ - 'instance_url' => $platformInstance->url, - 'is_active' => true - ]); - - $channelData = [ - 'name' => 'test_community', - 'platform_instance_id' => $platformInstance->id, - 'language_id' => $language->id, - 'description' => 'Test community description', - ]; - - $response = $this->postJson('/api/v1/onboarding/channel', $channelData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'name' => 'test_community', - 'display_name' => 'Test_community', - 'channel_id' => 'test_community', - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('platform_channels', [ - 'name' => 'test_community', - 'channel_id' => 'test_community', - 'platform_instance_id' => $platformInstance->id, - 'language_id' => $language->id, - 'is_active' => true, - ]); - } - - public function test_create_route_validates_required_fields() - { - $response = $this->postJson('/api/v1/onboarding/route', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['feed_id', 'platform_channel_id']); - } - - public function test_create_route_creates_route_successfully() - { - $language = Language::first(); - $feed = Feed::factory()->language($language)->create(); - $platformChannel = PlatformChannel::factory()->create(); - - $routeData = [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $platformChannel->id, - 'priority' => 75, - ]; - - $response = $this->postJson('/api/v1/onboarding/route', $routeData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $platformChannel->id, - 'priority' => 75, - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('routes', [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $platformChannel->id, - 'priority' => 75, - 'is_active' => true, - ]); - } - - public function test_complete_onboarding_returns_success() - { - $response = $this->postJson('/api/v1/onboarding/complete'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => ['completed' => true] - ]); - } - - public function test_skip_onboarding_creates_setting() - { - $response = $this->postJson('/api/v1/onboarding/skip'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => ['skipped' => true] - ]); - - $this->assertDatabaseHas('settings', [ - 'key' => 'onboarding_skipped', - 'value' => 'true', - ]); - } - - public function test_skip_onboarding_updates_existing_setting() - { - // Create existing setting with false value - Setting::create([ - 'key' => 'onboarding_skipped', - 'value' => 'false', - ]); - - $response = $this->postJson('/api/v1/onboarding/skip'); - - $response->assertStatus(200); - - $this->assertDatabaseHas('settings', [ - 'key' => 'onboarding_skipped', - 'value' => 'true', - ]); - - // Ensure only one setting exists - $this->assertEquals(1, Setting::where('key', 'onboarding_skipped')->count()); - } - - public function test_reset_skip_removes_setting() - { - // Create skipped setting - Setting::create([ - 'key' => 'onboarding_skipped', - 'value' => 'true', - ]); - - $response = $this->postJson('/api/v1/onboarding/reset-skip'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => ['reset' => true] - ]); - - $this->assertDatabaseMissing('settings', [ - 'key' => 'onboarding_skipped', - ]); - } - - public function test_reset_skip_works_when_no_setting_exists() - { - $response = $this->postJson('/api/v1/onboarding/reset-skip'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => ['reset' => true] - ]); - } - - public function test_create_platform_validates_instance_url_format() - { - $response = $this->postJson('/api/v1/onboarding/platform', [ - 'instance_url' => 'invalid.domain.with.spaces and symbols!', - 'username' => 'testuser', - 'password' => 'password123', - 'platform' => 'lemmy', - ]); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['instance_url']); - } - - public function test_create_platform_validates_required_fields() - { - $response = $this->postJson('/api/v1/onboarding/platform', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['instance_url', 'username', 'password', 'platform']); - } - - public function test_onboarding_flow_integration() - { - // 1. Initial status - needs onboarding - $response = $this->getJson('/api/v1/onboarding/status'); - $response->assertJson(['data' => ['needs_onboarding' => true, 'current_step' => 'platform']]); - - // 2. Skip onboarding - $response = $this->postJson('/api/v1/onboarding/skip'); - $response->assertJson(['data' => ['skipped' => true]]); - - // 3. Status after skip - no longer needs onboarding - $response = $this->getJson('/api/v1/onboarding/status'); - $response->assertJson(['data' => ['needs_onboarding' => false, 'onboarding_skipped' => true]]); - - // 4. Reset skip - $response = $this->postJson('/api/v1/onboarding/reset-skip'); - $response->assertJson(['data' => ['reset' => true]]); - - // 5. Status after reset - needs onboarding again - $response = $this->getJson('/api/v1/onboarding/status'); - $response->assertJson(['data' => ['needs_onboarding' => true, 'onboarding_skipped' => false]]); - } -} \ No newline at end of file diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php deleted file mode 100644 index 252fdcc..0000000 --- a/tests/Feature/ProfileTest.php +++ /dev/null @@ -1,99 +0,0 @@ -create(); - - $response = $this - ->actingAs($user) - ->get('/profile'); - - $response->assertOk(); - } - - public function test_profile_information_can_be_updated(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->patch('/profile', [ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); - - $user->refresh(); - - $this->assertSame('Test User', $user->name); - $this->assertSame('test@example.com', $user->email); - $this->assertNull($user->email_verified_at); - } - - public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->patch('/profile', [ - 'name' => 'Test User', - 'email' => $user->email, - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); - - $this->assertNotNull($user->refresh()->email_verified_at); - } - - public function test_user_can_delete_their_account(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->delete('/profile', [ - 'password' => 'password', - ]); - - $response - ->assertSessionHasNoErrors() - ->assertRedirect('/'); - - $this->assertGuest(); - $this->assertNull($user->fresh()); - } - - public function test_correct_password_must_be_provided_to_delete_account(): void - { - $user = User::factory()->create(); - - $response = $this - ->actingAs($user) - ->from('/profile') - ->delete('/profile', [ - 'password' => 'wrong-password', - ]); - - $response - ->assertSessionHasErrorsIn('userDeletion', 'password') - ->assertRedirect('/profile'); - - $this->assertNotNull($user->fresh()); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index da6d365..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,43 +0,0 @@ -shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - } - - return new ArticleFetcher($logSaver); - } - - protected function createArticleFetcherWithMockedLogSaver(): array - { - $logSaver = Mockery::mock(LogSaver::class); - $logSaver->shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - - $articleFetcher = new ArticleFetcher($logSaver); - - return [$articleFetcher, $logSaver]; - } -} \ No newline at end of file diff --git a/tests/Unit/Enums/LogLevelEnumTest.php b/tests/Unit/Enums/LogLevelEnumTest.php deleted file mode 100644 index 88084f3..0000000 --- a/tests/Unit/Enums/LogLevelEnumTest.php +++ /dev/null @@ -1,108 +0,0 @@ -assertEquals('debug', LogLevelEnum::DEBUG->value); - $this->assertEquals('info', LogLevelEnum::INFO->value); - $this->assertEquals('warning', LogLevelEnum::WARNING->value); - $this->assertEquals('error', LogLevelEnum::ERROR->value); - $this->assertEquals('critical', LogLevelEnum::CRITICAL->value); - } - - public function test_to_array_returns_all_enum_values(): void - { - $expected = ['debug', 'info', 'warning', 'error', 'critical']; - $actual = LogLevelEnum::toArray(); - - $this->assertEquals($expected, $actual); - $this->assertCount(5, $actual); - } - - public function test_enum_cases_exist(): void - { - $cases = LogLevelEnum::cases(); - - $this->assertCount(5, $cases); - $this->assertContains(LogLevelEnum::DEBUG, $cases); - $this->assertContains(LogLevelEnum::INFO, $cases); - $this->assertContains(LogLevelEnum::WARNING, $cases); - $this->assertContains(LogLevelEnum::ERROR, $cases); - $this->assertContains(LogLevelEnum::CRITICAL, $cases); - } - - public function test_enum_names_are_correct(): void - { - $this->assertEquals('DEBUG', LogLevelEnum::DEBUG->name); - $this->assertEquals('INFO', LogLevelEnum::INFO->name); - $this->assertEquals('WARNING', LogLevelEnum::WARNING->name); - $this->assertEquals('ERROR', LogLevelEnum::ERROR->name); - $this->assertEquals('CRITICAL', LogLevelEnum::CRITICAL->name); - } - - public function test_can_create_enum_from_string(): void - { - $this->assertEquals(LogLevelEnum::DEBUG, LogLevelEnum::from('debug')); - $this->assertEquals(LogLevelEnum::INFO, LogLevelEnum::from('info')); - $this->assertEquals(LogLevelEnum::WARNING, LogLevelEnum::from('warning')); - $this->assertEquals(LogLevelEnum::ERROR, LogLevelEnum::from('error')); - $this->assertEquals(LogLevelEnum::CRITICAL, LogLevelEnum::from('critical')); - } - - public function test_try_from_with_valid_values(): void - { - $this->assertEquals(LogLevelEnum::DEBUG, LogLevelEnum::tryFrom('debug')); - $this->assertEquals(LogLevelEnum::INFO, LogLevelEnum::tryFrom('info')); - $this->assertEquals(LogLevelEnum::WARNING, LogLevelEnum::tryFrom('warning')); - $this->assertEquals(LogLevelEnum::ERROR, LogLevelEnum::tryFrom('error')); - $this->assertEquals(LogLevelEnum::CRITICAL, LogLevelEnum::tryFrom('critical')); - } - - public function test_try_from_with_invalid_value_returns_null(): void - { - $this->assertNull(LogLevelEnum::tryFrom('invalid')); - $this->assertNull(LogLevelEnum::tryFrom('')); - $this->assertNull(LogLevelEnum::tryFrom('CRITICAL')); // case sensitive - } - - public function test_from_throws_exception_for_invalid_value(): void - { - $this->expectException(\ValueError::class); - LogLevelEnum::from('invalid'); - } - - public function test_enum_can_be_compared(): void - { - $debug1 = LogLevelEnum::DEBUG; - $debug2 = LogLevelEnum::DEBUG; - $info = LogLevelEnum::INFO; - - $this->assertTrue($debug1 === $debug2); - $this->assertFalse($debug1 === $info); - } - - public function test_enum_can_be_used_in_match_expression(): void - { - $getMessage = function (LogLevelEnum $level): string { - return match ($level) { - LogLevelEnum::DEBUG => 'Debug message', - LogLevelEnum::INFO => 'Info message', - LogLevelEnum::WARNING => 'Warning message', - LogLevelEnum::ERROR => 'Error message', - LogLevelEnum::CRITICAL => 'Critical message', - }; - }; - - $this->assertEquals('Debug message', $getMessage(LogLevelEnum::DEBUG)); - $this->assertEquals('Info message', $getMessage(LogLevelEnum::INFO)); - $this->assertEquals('Warning message', $getMessage(LogLevelEnum::WARNING)); - $this->assertEquals('Error message', $getMessage(LogLevelEnum::ERROR)); - $this->assertEquals('Critical message', $getMessage(LogLevelEnum::CRITICAL)); - } -} \ No newline at end of file diff --git a/tests/Unit/Enums/PlatformEnumTest.php b/tests/Unit/Enums/PlatformEnumTest.php deleted file mode 100644 index ebd9348..0000000 --- a/tests/Unit/Enums/PlatformEnumTest.php +++ /dev/null @@ -1,89 +0,0 @@ -assertEquals('lemmy', PlatformEnum::LEMMY->value); - } - - public function test_enum_cases_exist(): void - { - $cases = PlatformEnum::cases(); - - $this->assertCount(1, $cases); - $this->assertContains(PlatformEnum::LEMMY, $cases); - } - - public function test_enum_names_are_correct(): void - { - $this->assertEquals('LEMMY', PlatformEnum::LEMMY->name); - } - - public function test_can_create_enum_from_string(): void - { - $this->assertEquals(PlatformEnum::LEMMY, PlatformEnum::from('lemmy')); - } - - public function test_try_from_with_valid_values(): void - { - $this->assertEquals(PlatformEnum::LEMMY, PlatformEnum::tryFrom('lemmy')); - } - - public function test_try_from_with_invalid_value_returns_null(): void - { - $this->assertNull(PlatformEnum::tryFrom('reddit')); - $this->assertNull(PlatformEnum::tryFrom('mastodon')); - $this->assertNull(PlatformEnum::tryFrom('')); - $this->assertNull(PlatformEnum::tryFrom('LEMMY')); // case sensitive - } - - public function test_from_throws_exception_for_invalid_value(): void - { - $this->expectException(\ValueError::class); - PlatformEnum::from('reddit'); - } - - public function test_enum_can_be_compared(): void - { - $lemmy1 = PlatformEnum::LEMMY; - $lemmy2 = PlatformEnum::LEMMY; - - $this->assertTrue($lemmy1 === $lemmy2); - } - - public function test_enum_can_be_used_in_match_expression(): void - { - $getDescription = function (PlatformEnum $platform): string { - return match ($platform) { - PlatformEnum::LEMMY => 'Lemmy is a federated link aggregator', - }; - }; - - $this->assertEquals('Lemmy is a federated link aggregator', $getDescription(PlatformEnum::LEMMY)); - } - - public function test_enum_can_be_used_in_switch_statement(): void - { - $platform = PlatformEnum::LEMMY; - $result = ''; - - switch ($platform) { - case PlatformEnum::LEMMY: - $result = 'lemmy platform'; - break; - } - - $this->assertEquals('lemmy platform', $result); - } - - public function test_enum_value_is_string_backed(): void - { - $this->assertIsString(PlatformEnum::LEMMY->value); - } -} \ No newline at end of file diff --git a/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php b/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php deleted file mode 100644 index 2b6dd9e..0000000 --- a/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php +++ /dev/null @@ -1,162 +0,0 @@ -create(['short_code' => 'en', 'name' => 'English']); - $frenchLang = Language::factory()->create(['short_code' => 'fr', 'name' => 'French']); - - $feed = new Feed(['name' => 'Test Feed']); - $feed->setRelation('language', $englishLang); - - $channel = new PlatformChannel(['name' => 'Test Channel']); - $channel->setRelation('language', $frenchLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $message = $exception->getMessage(); - $this->assertStringContainsString('Language mismatch:', $message); - $this->assertStringContainsString('Test Feed', $message); - $this->assertStringContainsString('Test Channel', $message); - $this->assertStringContainsString('Feed and channel languages must match', $message); - } - - public function test_exception_extends_routing_exception(): void - { - // Arrange - $englishLang = Language::factory()->create(['short_code' => 'en']); - $frenchLang = Language::factory()->create(['short_code' => 'fr']); - - $feed = new Feed(['name' => 'Test Feed']); - $feed->setRelation('language', $englishLang); - - $channel = new PlatformChannel(['name' => 'Test Channel']); - $channel->setRelation('language', $frenchLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $this->assertInstanceOf(\App\Exceptions\RoutingException::class, $exception); - } - - public function test_exception_with_different_languages(): void - { - // Arrange - $dutchLang = Language::factory()->create(['short_code' => 'nl', 'name' => 'Dutch']); - $germanLang = Language::factory()->create(['short_code' => 'de', 'name' => 'German']); - - $feed = new Feed(['name' => 'Dutch News']); - $feed->setRelation('language', $dutchLang); - - $channel = new PlatformChannel(['name' => 'German Channel']); - $channel->setRelation('language', $germanLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $message = $exception->getMessage(); - $this->assertStringContainsString('Dutch News', $message); - $this->assertStringContainsString('German Channel', $message); - $this->assertStringContainsString('Language mismatch', $message); - } - - public function test_exception_message_contains_all_required_elements(): void - { - // Arrange - $frenchLang = Language::factory()->create(['short_code' => 'fr', 'name' => 'French']); - $spanishLang = Language::factory()->create(['short_code' => 'es', 'name' => 'Spanish']); - - $feed = new Feed(['name' => 'French Feed']); - $feed->setRelation('language', $frenchLang); - - $channel = new PlatformChannel(['name' => 'Spanish Channel']); - $channel->setRelation('language', $spanishLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - $message = $exception->getMessage(); - - // Assert - $this->assertStringContainsString('Language mismatch:', $message); - $this->assertStringContainsString('French Feed', $message); - $this->assertStringContainsString('Spanish Channel', $message); - $this->assertStringContainsString('Feed and channel languages must match', $message); - } - - public function test_exception_with_null_languages(): void - { - // Arrange - $feed = new Feed(['name' => 'No Lang Feed']); - $feed->setRelation('language', null); - - $channel = new PlatformChannel(['name' => 'No Lang Channel']); - $channel->setRelation('language', null); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $message = $exception->getMessage(); - $this->assertStringContainsString('No Lang Feed', $message); - $this->assertStringContainsString('No Lang Channel', $message); - $this->assertIsString($message); - } - - public function test_exception_with_special_characters_in_names(): void - { - // Arrange - $englishLang = Language::factory()->create(['short_code' => 'en']); - $frenchLang = Language::factory()->create(['short_code' => 'fr']); - - $feed = new Feed(['name' => 'Feed with "quotes" & symbols']); - $feed->setRelation('language', $englishLang); - - $channel = new PlatformChannel(['name' => 'Channel with ']); - $channel->setRelation('language', $frenchLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $message = $exception->getMessage(); - $this->assertStringContainsString('Feed with "quotes" & symbols', $message); - $this->assertStringContainsString('Channel with ', $message); - $this->assertIsString($message); - } - - public function test_exception_is_throwable(): void - { - // Arrange - $englishLang = Language::factory()->create(['short_code' => 'en']); - $frenchLang = Language::factory()->create(['short_code' => 'fr']); - - $feed = new Feed(['name' => 'Test Feed']); - $feed->setRelation('language', $englishLang); - - $channel = new PlatformChannel(['name' => 'Test Channel']); - $channel->setRelation('language', $frenchLang); - - // Act & Assert - $this->expectException(RoutingMismatchException::class); - $this->expectExceptionMessage('Language mismatch'); - - throw new RoutingMismatchException($feed, $channel); - } -} \ No newline at end of file diff --git a/tests/Unit/Facades/LogSaverTest.php b/tests/Unit/Facades/LogSaverTest.php deleted file mode 100644 index db240c2..0000000 --- a/tests/Unit/Facades/LogSaverTest.php +++ /dev/null @@ -1,135 +0,0 @@ -getMethod('getFacadeAccessor'); - $method->setAccessible(true); - - $this->assertEquals(\App\Services\Log\LogSaver::class, $method->invoke(null)); - } - - public function test_facade_info_method_works(): void - { - $message = 'Facade info test'; - $context = ['facade' => true]; - - LogSaver::info($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::INFO, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_facade_error_method_works(): void - { - $message = 'Facade error test'; - $context = ['facade' => true, 'error' => 'test_error']; - - LogSaver::error($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::ERROR, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_facade_warning_method_works(): void - { - $message = 'Facade warning test'; - $context = ['facade' => true, 'warning_type' => 'test']; - - LogSaver::warning($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::WARNING, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_facade_debug_method_works(): void - { - $message = 'Facade debug test'; - $context = ['facade' => true, 'debug_info' => 'test']; - - LogSaver::debug($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::DEBUG, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_facade_works_with_channel(): void - { - $platformInstance = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://facade.test.com' - ]); - - $channel = PlatformChannel::factory()->create([ - 'name' => 'Facade Test Channel', - 'platform_instance_id' => $platformInstance->id - ]); - - $message = 'Facade channel test'; - $context = ['facade_test' => true]; - - LogSaver::info($message, $channel, $context); - - $log = Log::first(); - - $expectedContext = array_merge($context, [ - 'channel_id' => $channel->id, - 'channel_name' => 'Facade Test Channel', - 'platform' => PlatformEnum::LEMMY->value, - 'instance_url' => 'https://facade.test.com', - ]); - - $this->assertEquals($expectedContext, $log->context); - $this->assertEquals($message, $log->message); - $this->assertEquals(LogLevelEnum::INFO, $log->level); - } - - public function test_facade_static_calls_resolve_to_service_instance(): void - { - LogSaver::info('Test message 1'); - LogSaver::error('Test message 2'); - - $this->assertDatabaseCount('logs', 2); - - $logs = Log::orderBy('id')->get(); - $this->assertEquals('Test message 1', $logs[0]->message); - $this->assertEquals('Test message 2', $logs[1]->message); - $this->assertEquals(LogLevelEnum::INFO, $logs[0]->level); - $this->assertEquals(LogLevelEnum::ERROR, $logs[1]->level); - } -} \ No newline at end of file diff --git a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php deleted file mode 100644 index e33e3a6..0000000 --- a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php +++ /dev/null @@ -1,222 +0,0 @@ -make(); - $job = new ArticleDiscoveryForFeedJob($feed); - - $this->assertEquals('feed-discovery', $job->queue); - } - - public function test_job_implements_should_queue(): void - { - $feed = Feed::factory()->make(); - $job = new ArticleDiscoveryForFeedJob($feed); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); - } - - public function test_job_uses_queueable_trait(): void - { - $feed = Feed::factory()->make(); - $job = new ArticleDiscoveryForFeedJob($feed); - - $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, - class_uses($job) - ); - } - - public function test_handle_fetches_articles_and_updates_feed(): void - { - // Arrange - $feed = Feed::factory()->create([ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed', - 'last_fetched_at' => null - ]); - - $mockArticles = collect(['article1', 'article2']); - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('getArticlesFromFeed') - ->once() - ->with($feed) - ->andReturn($mockArticles); - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting feed article fetch', null, [ - 'feed_id' => $feed->id, - 'feed_name' => $feed->name, - 'feed_url' => $feed->url - ]) - ->once(); - - $logSaverMock->shouldReceive('info') - ->with('Feed article fetch completed', null, [ - 'feed_id' => $feed->id, - 'feed_name' => $feed->name, - 'articles_count' => 2 - ]) - ->once(); - - $job = new ArticleDiscoveryForFeedJob($feed); - - // Act - $job->handle($logSaverMock, $articleFetcherMock); - - // Assert - $feed->refresh(); - $this->assertNotNull($feed->last_fetched_at); - $this->assertTrue($feed->last_fetched_at->greaterThan(now()->subMinute())); - } - - public function test_dispatch_for_all_active_feeds_dispatches_jobs_with_delay(): void - { - // Arrange - $feeds = Feed::factory()->count(3)->create(['is_active' => true]); - Feed::factory()->create(['is_active' => false]); // inactive feed should be ignored - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->times(3) // Once for each active feed - ->with('Dispatched feed discovery job', null, Mockery::type('array')); - - $this->app->instance(LogSaver::class, $logSaverMock); - - // Act - ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - - // Assert - Queue::assertPushed(ArticleDiscoveryForFeedJob::class, 3); - - // Verify jobs were dispatched (cannot access private $feed property in test) - } - - public function test_dispatch_for_all_active_feeds_applies_correct_delays(): void - { - // Arrange - Feed::factory()->count(2)->create(['is_active' => true]); - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info')->times(2); - - $this->app->instance(LogSaver::class, $logSaverMock); - - // Act - ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - - // Assert - Queue::assertPushed(ArticleDiscoveryForFeedJob::class, 2); - - // Verify jobs are pushed with delays - Queue::assertPushed(ArticleDiscoveryForFeedJob::class, function ($job) { - return $job->delay !== null; - }); - } - - public function test_dispatch_for_all_active_feeds_with_no_active_feeds(): void - { - // Arrange - Feed::factory()->count(2)->create(['is_active' => false]); - - // Act - ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - - // Assert - Queue::assertNothingPushed(); - } - - public function test_feed_discovery_delay_constant_exists(): void - { - $reflection = new \ReflectionClass(ArticleDiscoveryForFeedJob::class); - $constant = $reflection->getConstant('FEED_DISCOVERY_DELAY_MINUTES'); - - $this->assertEquals(5, $constant); - } - - public function test_job_can_be_serialized(): void - { - $feed = Feed::factory()->create(['name' => 'Test Feed']); - $job = new ArticleDiscoveryForFeedJob($feed); - - $serialized = serialize($job); - $unserialized = unserialize($serialized); - - $this->assertInstanceOf(ArticleDiscoveryForFeedJob::class, $unserialized); - $this->assertEquals($job->queue, $unserialized->queue); - // Note: Cannot test feed property directly as it's private - // but serialization/unserialization working proves the job structure is intact - } - - public function test_handle_logs_start_message_with_correct_context(): void - { - // Arrange - $feed = Feed::factory()->create([ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed' - ]); - - $mockArticles = collect([]); - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('getArticlesFromFeed') - ->once() - ->andReturn($mockArticles); - - // Mock LogSaver with specific expectations - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting feed article fetch', null, [ - 'feed_id' => $feed->id, - 'feed_name' => 'Test Feed', - 'feed_url' => 'https://example.com/feed' - ]) - ->once(); - - $logSaverMock->shouldReceive('info') - ->with('Feed article fetch completed', null, Mockery::type('array')) - ->once(); - - $job = new ArticleDiscoveryForFeedJob($feed); - - // Act - $job->handle($logSaverMock, $articleFetcherMock); - - // Assert - Mockery expectations are verified in tearDown - $this->assertTrue(true); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} \ No newline at end of file diff --git a/tests/Unit/Jobs/ArticleDiscoveryJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryJobTest.php deleted file mode 100644 index 4db26ae..0000000 --- a/tests/Unit/Jobs/ArticleDiscoveryJobTest.php +++ /dev/null @@ -1,146 +0,0 @@ -assertEquals('feed-discovery', $job->queue); - } - - public function test_handle_skips_when_article_processing_disabled(): void - { - // Arrange - Setting::create(['key' => 'article_processing_enabled', 'value' => '0']); - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->once() - ->with('Article processing is disabled. Article discovery skipped.'); - - $job = new ArticleDiscoveryJob(); - - // Act - $job->handle($logSaverMock); - - // Assert - Queue::assertNothingPushed(); - } - - public function test_handle_dispatches_jobs_when_article_processing_enabled(): void - { - // Arrange - Setting::create(['key' => 'article_processing_enabled', 'value' => '1']); - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting article discovery for all active feeds') - ->once(); - $logSaverMock->shouldReceive('info') - ->with('Article discovery jobs dispatched for all active feeds') - ->once(); - - $job = new ArticleDiscoveryJob(); - - // Act - $job->handle($logSaverMock); - - // Assert - This will test that the static method is called, but we can't easily verify - // the job dispatch without mocking the static method - $this->assertTrue(true); // Job completes without error - } - - public function test_handle_with_default_article_processing_enabled(): void - { - // Arrange - No setting exists, should default to enabled - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting article discovery for all active feeds') - ->once(); - $logSaverMock->shouldReceive('info') - ->with('Article discovery jobs dispatched for all active feeds') - ->once(); - - $job = new ArticleDiscoveryJob(); - - // Act - $job->handle($logSaverMock); - - // Assert - Should complete without skipping - $this->assertTrue(true); // Job completes without error - } - - public function test_job_implements_should_queue(): void - { - // Arrange - $job = new ArticleDiscoveryJob(); - - // Assert - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); - } - - public function test_job_uses_queueable_trait(): void - { - // Arrange - $job = new ArticleDiscoveryJob(); - - // Assert - $this->assertTrue(method_exists($job, 'onQueue')); - $this->assertTrue(method_exists($job, 'onConnection')); - $this->assertTrue(method_exists($job, 'delay')); - } - - public function test_handle_logs_appropriate_messages(): void - { - // This test verifies that the job calls the logging methods - // The actual logging is tested in the LogSaver tests - - // Arrange - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting article discovery for all active feeds') - ->once(); - $logSaverMock->shouldReceive('info') - ->with('Article discovery jobs dispatched for all active feeds') - ->once(); - - $job = new ArticleDiscoveryJob(); - - // Act - Should not throw any exceptions - $job->handle($logSaverMock); - - // Assert - Job completes successfully - $this->assertTrue(true); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php deleted file mode 100644 index 9e8f6cf..0000000 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ /dev/null @@ -1,296 +0,0 @@ -assertEquals('publishing', $job->queue); - } - - public function test_job_implements_should_queue(): void - { - $job = new PublishNextArticleJob(); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); - } - - public function test_job_implements_should_be_unique(): void - { - $job = new PublishNextArticleJob(); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job); - } - - public function test_job_has_unique_for_property(): void - { - $job = new PublishNextArticleJob(); - - $this->assertEquals(300, $job->uniqueFor); - } - - public function test_job_uses_queueable_trait(): void - { - $job = new PublishNextArticleJob(); - - $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, - class_uses($job) - ); - } - - public function test_handle_returns_early_when_no_approved_articles(): void - { - // Arrange - No articles exist - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early - - $job = new PublishNextArticleJob(); - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Should complete without error - $this->assertTrue(true); - } - - public function test_handle_returns_early_when_no_unpublished_approved_articles(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved' - ]); - - // Create a publication record to mark it as already published - ArticlePublication::factory()->create(['article_id' => $article->id]); - - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early - - $job = new PublishNextArticleJob(); - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Should complete without error - $this->assertTrue(true); - } - - public function test_handle_skips_non_approved_articles(): void - { - // Arrange - $feed = Feed::factory()->create(); - Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending' - ]); - Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'rejected' - ]); - - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early - - $job = new PublishNextArticleJob(); - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Should complete without error (no approved articles to process) - $this->assertTrue(true); - } - - public function test_handle_publishes_oldest_approved_article(): void - { - // Arrange - $feed = Feed::factory()->create(); - - // Create older article first - $olderArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'created_at' => now()->subHours(2) - ]); - - // Create newer article - $newerArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'created_at' => now()->subHour() - ]); - - $extractedData = ['title' => 'Test Article', 'content' => 'Test content']; - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->with(Mockery::on(function ($article) use ($olderArticle) { - return $article->id === $olderArticle->id; - })) - ->andReturn($extractedData); - - // Mock ArticlePublishingService - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->with( - Mockery::on(function ($article) use ($olderArticle) { - return $article->id === $olderArticle->id; - }), - $extractedData - ); - - $job = new PublishNextArticleJob(); - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Mockery expectations are verified in tearDown - $this->assertTrue(true); - } - - public function test_handle_throws_exception_on_publishing_failure(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = ['title' => 'Test Article']; - $publishException = new PublishException($article, null); - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->with(Mockery::type(Article::class)) - ->andReturn($extractedData); - - // Mock ArticlePublishingService to throw exception - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->andThrow($publishException); - - $job = new PublishNextArticleJob(); - - // Assert - $this->expectException(PublishException::class); - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock); - } - - public function test_handle_logs_publishing_start(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'title' => 'Test Article Title', - 'url' => 'https://example.com/article' - ]); - - $extractedData = ['title' => 'Test Article']; - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->andReturn($extractedData); - - // Mock ArticlePublishingService - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels')->once(); - - $job = new PublishNextArticleJob(); - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Verify the job completes (logging is verified by observing no exceptions) - $this->assertTrue(true); - } - - public function test_job_can_be_serialized(): void - { - $job = new PublishNextArticleJob(); - - $serialized = serialize($job); - $unserialized = unserialize($serialized); - - $this->assertInstanceOf(PublishNextArticleJob::class, $unserialized); - $this->assertEquals($job->queue, $unserialized->queue); - $this->assertEquals($job->uniqueFor, $unserialized->uniqueFor); - } - - public function test_handle_fetches_article_data_before_publishing(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = ['title' => 'Extracted Title', 'content' => 'Extracted Content']; - - // Mock ArticleFetcher with specific expectations - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->with(Mockery::type(Article::class)) - ->andReturn($extractedData); - - // Mock publishing service to receive the extracted data - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->with(Mockery::type(Article::class), $extractedData); - - $job = new PublishNextArticleJob(); - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Mockery expectations verified in tearDown - $this->assertTrue(true); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} \ No newline at end of file diff --git a/tests/Unit/Jobs/SyncChannelPostsJobTest.php b/tests/Unit/Jobs/SyncChannelPostsJobTest.php deleted file mode 100644 index 6b10a61..0000000 --- a/tests/Unit/Jobs/SyncChannelPostsJobTest.php +++ /dev/null @@ -1,170 +0,0 @@ -make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertEquals('sync', $job->queue); - } - - public function test_job_implements_should_queue(): void - { - $channel = PlatformChannel::factory()->make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); - } - - public function test_job_implements_should_be_unique(): void - { - $channel = PlatformChannel::factory()->make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job); - } - - public function test_job_uses_queueable_trait(): void - { - $channel = PlatformChannel::factory()->make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, - class_uses($job) - ); - } - - public function test_dispatch_for_all_active_channels_dispatches_jobs(): void - { - // Arrange - $platformInstance = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY - ]); - - $account = PlatformAccount::factory()->create([ - 'instance_url' => $platformInstance->url, - 'is_active' => true - ]); - - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $platformInstance->id, - 'is_active' => true - ]); - - // Attach account to channel with active status - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'created_at' => now(), - 'updated_at' => now() - ]); - - // Mock LogSaver to avoid strict expectations - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info')->zeroOrMoreTimes(); - - $this->app->instance(LogSaver::class, $logSaverMock); - - // Act - SyncChannelPostsJob::dispatchForAllActiveChannels(); - - // Assert - At least one job should be dispatched - Queue::assertPushed(SyncChannelPostsJob::class); - } - - public function test_handle_logs_start_message(): void - { - // Arrange - $platformInstance = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://lemmy.example.com' - ]); - - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $platformInstance->id, - 'name' => 'testcommunity' - ]); - - // Mock LogSaver - only test that logging methods are called - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info')->atLeast()->once(); - $logSaverMock->shouldReceive('error')->zeroOrMoreTimes(); - - $job = new SyncChannelPostsJob($channel); - - // Act - This will fail due to no active account, but we test the logging - try { - $job->handle($logSaverMock); - } catch (Exception $e) { - // Expected to fail, we're testing that logging is called - } - - // Assert - Test completes if no exceptions during setup - $this->assertTrue(true); - } - - public function test_job_can_be_serialized(): void - { - $platformInstance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $platformInstance->id, - 'name' => 'Test Channel' - ]); - $job = new SyncChannelPostsJob($channel); - - $serialized = serialize($job); - $unserialized = unserialize($serialized); - - $this->assertInstanceOf(SyncChannelPostsJob::class, $unserialized); - $this->assertEquals($job->queue, $unserialized->queue); - // Note: Cannot test channel property directly as it's private - // but serialization/unserialization working proves the job structure is intact - } - - public function test_dispatch_for_all_active_channels_method_exists(): void - { - $this->assertTrue(method_exists(SyncChannelPostsJob::class, 'dispatchForAllActiveChannels')); - } - - public function test_job_has_handle_method(): void - { - $channel = PlatformChannel::factory()->make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertTrue(method_exists($job, 'handle')); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/ArticlePublicationTest.php b/tests/Unit/Models/ArticlePublicationTest.php deleted file mode 100644 index 0a0c1de..0000000 --- a/tests/Unit/Models/ArticlePublicationTest.php +++ /dev/null @@ -1,306 +0,0 @@ -assertEquals($fillableFields, $publication->getFillable()); - } - - public function test_table_name(): void - { - $publication = new ArticlePublication(); - - $this->assertEquals('article_publications', $publication->getTable()); - } - - public function test_casts_published_at_to_datetime(): void - { - $timestamp = now()->subHours(2); - $publication = ArticlePublication::factory()->create(['published_at' => $timestamp]); - - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s')); - } - - public function test_casts_publication_data_to_array(): void - { - $publicationData = [ - 'post_url' => 'https://lemmy.world/post/123', - 'platform_response' => [ - 'id' => 123, - 'status' => 'success', - 'metadata' => ['views' => 0, 'votes' => 0] - ], - 'retry_count' => 0 - ]; - - $publication = ArticlePublication::factory()->create(['publication_data' => $publicationData]); - - $this->assertIsArray($publication->publication_data); - $this->assertEquals($publicationData, $publication->publication_data); - } - - public function test_belongs_to_article_relationship(): void - { - $article = Article::factory()->create(); - $publication = ArticlePublication::factory()->create(['article_id' => $article->id]); - - $this->assertInstanceOf(Article::class, $publication->article); - $this->assertEquals($article->id, $publication->article->id); - $this->assertEquals($article->title, $publication->article->title); - } - - public function test_publication_creation_with_factory(): void - { - $publication = ArticlePublication::factory()->create(); - - $this->assertInstanceOf(ArticlePublication::class, $publication); - $this->assertNotNull($publication->article_id); - $this->assertNotNull($publication->platform_channel_id); - $this->assertIsString($publication->post_id); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); - $this->assertIsString($publication->published_by); - } - - public function test_publication_creation_with_explicit_values(): void - { - $article = Article::factory()->create(); - $channel = PlatformChannel::factory()->create(); - $publicationData = ['status' => 'success', 'external_id' => '12345']; - $publishedAt = now()->subHours(1); - - $publication = ArticlePublication::create([ - 'article_id' => $article->id, - 'platform_channel_id' => $channel->id, - 'post_id' => 'post-123', - 'published_at' => $publishedAt, - 'published_by' => 'test_bot', - 'platform' => 'lemmy', - 'publication_data' => $publicationData - ]); - - $this->assertEquals($article->id, $publication->article_id); - $this->assertEquals($channel->id, $publication->platform_channel_id); - $this->assertEquals('post-123', $publication->post_id); - $this->assertEquals($publishedAt->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s')); - $this->assertEquals('test_bot', $publication->published_by); - $this->assertEquals('lemmy', $publication->platform); - $this->assertEquals($publicationData, $publication->publication_data); - } - - public function test_publication_factory_recently_published_state(): void - { - $publication = ArticlePublication::factory()->recentlyPublished()->create(); - - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); - $this->assertTrue($publication->published_at->isAfter(now()->subDay())); - $this->assertTrue($publication->published_at->isBefore(now()->addMinute())); - } - - public function test_publication_update(): void - { - $publication = ArticlePublication::factory()->create([ - 'post_id' => 'original-id', - 'published_by' => 'original_user' - ]); - - $publication->update([ - 'post_id' => 'updated-id', - 'published_by' => 'updated_user' - ]); - - $publication->refresh(); - - $this->assertEquals('updated-id', $publication->post_id); - $this->assertEquals('updated_user', $publication->published_by); - } - - public function test_publication_deletion(): void - { - $publication = ArticlePublication::factory()->create(); - $publicationId = $publication->id; - - $publication->delete(); - - $this->assertDatabaseMissing('article_publications', ['id' => $publicationId]); - } - - public function test_publication_data_can_be_empty_array(): void - { - $publication = ArticlePublication::factory()->create(['publication_data' => []]); - - $this->assertIsArray($publication->publication_data); - $this->assertEmpty($publication->publication_data); - } - - public function test_publication_data_can_be_null(): void - { - $publication = ArticlePublication::factory()->create(['publication_data' => null]); - - $this->assertNull($publication->publication_data); - } - - public function test_publication_data_can_be_complex_structure(): void - { - $complexData = [ - 'platform_response' => [ - 'post_id' => 'abc123', - 'url' => 'https://lemmy.world/post/abc123', - 'created_at' => '2023-01-01T12:00:00Z', - 'author' => [ - 'id' => 456, - 'name' => 'bot_user', - 'display_name' => 'Bot User' - ] - ], - 'metadata' => [ - 'retry_attempts' => 1, - 'processing_time_ms' => 1250, - 'error_log' => [] - ], - 'analytics' => [ - 'initial_views' => 0, - 'initial_votes' => 0, - 'engagement_tracked' => false - ] - ]; - - $publication = ArticlePublication::factory()->create(['publication_data' => $complexData]); - - $this->assertEquals($complexData, $publication->publication_data); - $this->assertEquals('abc123', $publication->publication_data['platform_response']['post_id']); - $this->assertEquals(1, $publication->publication_data['metadata']['retry_attempts']); - $this->assertFalse($publication->publication_data['analytics']['engagement_tracked']); - } - - public function test_publication_with_specific_published_at(): void - { - $timestamp = now()->subHours(3); - $publication = ArticlePublication::factory()->create(['published_at' => $timestamp]); - - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s')); - } - - public function test_publication_with_specific_published_by(): void - { - $publication = ArticlePublication::factory()->create(['published_by' => 'custom_bot']); - - $this->assertEquals('custom_bot', $publication->published_by); - } - - public function test_publication_with_specific_platform(): void - { - $publication = ArticlePublication::factory()->create(['platform' => 'lemmy']); - - $this->assertEquals('lemmy', $publication->platform); - } - - public function test_publication_timestamps(): void - { - $publication = ArticlePublication::factory()->create(); - - $this->assertNotNull($publication->created_at); - $this->assertNotNull($publication->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->updated_at); - } - - public function test_multiple_publications_for_same_article(): void - { - $article = Article::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - $publication1 = ArticlePublication::factory()->create([ - 'article_id' => $article->id, - 'platform_channel_id' => $channel1->id, - 'post_id' => 'post-1' - ]); - - $publication2 = ArticlePublication::factory()->create([ - 'article_id' => $article->id, - 'platform_channel_id' => $channel2->id, - 'post_id' => 'post-2' - ]); - - $this->assertEquals($article->id, $publication1->article_id); - $this->assertEquals($article->id, $publication2->article_id); - $this->assertNotEquals($publication1->platform_channel_id, $publication2->platform_channel_id); - $this->assertNotEquals($publication1->post_id, $publication2->post_id); - } - - public function test_publication_with_different_platforms(): void - { - $publication1 = ArticlePublication::factory()->create(['platform' => 'lemmy']); - $publication2 = ArticlePublication::factory()->create(['platform' => 'lemmy']); - - $this->assertEquals('lemmy', $publication1->platform); - $this->assertEquals('lemmy', $publication2->platform); - } - - public function test_publication_post_id_variations(): void - { - $publications = [ - ArticlePublication::factory()->create(['post_id' => 'numeric-123']), - ArticlePublication::factory()->create(['post_id' => 'uuid-' . fake()->uuid()]), - ArticlePublication::factory()->create(['post_id' => 'alphanumeric_post_456']), - ArticlePublication::factory()->create(['post_id' => '12345']), - ]; - - foreach ($publications as $publication) { - $this->assertIsString($publication->post_id); - $this->assertNotEmpty($publication->post_id); - } - } - - public function test_publication_data_with_error_information(): void - { - $errorData = [ - 'status' => 'failed', - 'error' => [ - 'code' => 403, - 'message' => 'Insufficient permissions', - 'details' => 'Bot account lacks posting privileges' - ], - 'retry_info' => [ - 'max_retries' => 3, - 'current_attempt' => 2, - 'next_retry_at' => '2023-01-01T13:00:00Z' - ] - ]; - - $publication = ArticlePublication::factory()->create(['publication_data' => $errorData]); - - $this->assertEquals('failed', $publication->publication_data['status']); - $this->assertEquals(403, $publication->publication_data['error']['code']); - $this->assertEquals(2, $publication->publication_data['retry_info']['current_attempt']); - } - - public function test_publication_relationship_with_article_data(): void - { - $article = Article::factory()->create([ - 'title' => 'Test Article Title', - 'description' => 'Test article description' - ]); - - $publication = ArticlePublication::factory()->create(['article_id' => $article->id]); - - $this->assertEquals('Test Article Title', $publication->article->title); - $this->assertEquals('Test article description', $publication->article->description); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/FeedTest.php b/tests/Unit/Models/FeedTest.php deleted file mode 100644 index 94beef8..0000000 --- a/tests/Unit/Models/FeedTest.php +++ /dev/null @@ -1,333 +0,0 @@ -assertEquals($fillableFields, $feed->getFillable()); - } - - public function test_casts_settings_to_array(): void - { - $settings = ['key1' => 'value1', 'key2' => ['nested' => 'value']]; - - $feed = Feed::factory()->create(['settings' => $settings]); - - $this->assertIsArray($feed->settings); - $this->assertEquals($settings, $feed->settings); - } - - public function test_casts_is_active_to_boolean(): void - { - $feed = Feed::factory()->create(['is_active' => '1']); - - $this->assertIsBool($feed->is_active); - $this->assertTrue($feed->is_active); - - $feed->update(['is_active' => '0']); - $feed->refresh(); - - $this->assertIsBool($feed->is_active); - $this->assertFalse($feed->is_active); - } - - public function test_casts_last_fetched_at_to_datetime(): void - { - $timestamp = now()->subHours(2); - $feed = Feed::factory()->create(['last_fetched_at' => $timestamp]); - - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->last_fetched_at); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $feed->last_fetched_at->format('Y-m-d H:i:s')); - } - - public function test_type_display_attribute(): void - { - $websiteFeed = Feed::factory()->create(['type' => 'website']); - $rssFeed = Feed::factory()->create(['type' => 'rss']); - - $this->assertEquals('Website', $websiteFeed->type_display); - $this->assertEquals('RSS Feed', $rssFeed->type_display); - } - - public function test_status_attribute_inactive_feed(): void - { - $feed = Feed::factory()->create(['is_active' => false]); - - $this->assertEquals('Inactive', $feed->status); - } - - public function test_status_attribute_never_fetched(): void - { - $feed = Feed::factory()->create([ - 'is_active' => true, - 'last_fetched_at' => null - ]); - - $this->assertEquals('Never fetched', $feed->status); - } - - public function test_status_attribute_recently_fetched(): void - { - $feed = Feed::factory()->create([ - 'is_active' => true, - 'last_fetched_at' => now()->subHour() - ]); - - $this->assertEquals('Recently fetched', $feed->status); - } - - public function test_status_attribute_fetched_hours_ago(): void - { - $feed = Feed::factory()->create([ - 'is_active' => true, - 'last_fetched_at' => now()->subHours(5)->startOfHour() - ]); - - $this->assertStringContainsString('Fetched', $feed->status); - $this->assertStringContainsString('ago', $feed->status); - } - - public function test_status_attribute_fetched_days_ago(): void - { - $feed = Feed::factory()->create([ - 'is_active' => true, - 'last_fetched_at' => now()->subDays(3) - ]); - - $this->assertStringStartsWith('Fetched', $feed->status); - $this->assertStringContainsString('ago', $feed->status); - } - - public function test_belongs_to_language_relationship(): void - { - $language = Language::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - - $this->assertInstanceOf(Language::class, $feed->language); - $this->assertEquals($language->id, $feed->language->id); - $this->assertEquals($language->name, $feed->language->name); - } - - public function test_has_many_articles_relationship(): void - { - $feed = Feed::factory()->create(); - - $article1 = Article::factory()->create(['feed_id' => $feed->id]); - $article2 = Article::factory()->create(['feed_id' => $feed->id]); - - // Create article for different feed - $otherFeed = Feed::factory()->create(); - Article::factory()->create(['feed_id' => $otherFeed->id]); - - $articles = $feed->articles; - - $this->assertCount(2, $articles); - $this->assertTrue($articles->contains('id', $article1->id)); - $this->assertTrue($articles->contains('id', $article2->id)); - $this->assertInstanceOf(Article::class, $articles->first()); - } - - public function test_belongs_to_many_channels_relationship(): void - { - $feed = Feed::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - // Create routes (which act as pivot records) - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => false, - 'priority' => 50 - ]); - - $channels = $feed->channels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - - // Test pivot data - $channel1FromRelation = $channels->find($channel1->id); - $this->assertEquals(1, $channel1FromRelation->pivot->is_active); - $this->assertEquals(100, $channel1FromRelation->pivot->priority); - } - - public function test_active_channels_relationship(): void - { - $feed = Feed::factory()->create(); - $activeChannel1 = PlatformChannel::factory()->create(); - $activeChannel2 = PlatformChannel::factory()->create(); - $inactiveChannel = PlatformChannel::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $activeChannel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $activeChannel2->id, - 'is_active' => true, - 'priority' => 200 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $inactiveChannel->id, - 'is_active' => false, - 'priority' => 150 - ]); - - $activeChannels = $feed->activeChannels; - - $this->assertCount(2, $activeChannels); - $this->assertTrue($activeChannels->contains('id', $activeChannel1->id)); - $this->assertTrue($activeChannels->contains('id', $activeChannel2->id)); - $this->assertFalse($activeChannels->contains('id', $inactiveChannel->id)); - - // Test ordering by priority descending - $channelIds = $activeChannels->pluck('id')->toArray(); - $this->assertEquals($activeChannel2->id, $channelIds[0]); // Priority 200 - $this->assertEquals($activeChannel1->id, $channelIds[1]); // Priority 100 - } - - public function test_feed_creation_with_factory(): void - { - $feed = Feed::factory()->create(); - - $this->assertInstanceOf(Feed::class, $feed); - $this->assertIsString($feed->name); - $this->assertIsString($feed->url); - $this->assertIsString($feed->type); - // Language ID may be null as it's nullable in the database - $this->assertTrue($feed->language_id === null || is_int($feed->language_id)); - $this->assertIsBool($feed->is_active); - $this->assertIsArray($feed->settings); - } - - public function test_feed_creation_with_explicit_values(): void - { - $language = Language::factory()->create(); - $settings = ['custom' => 'setting', 'nested' => ['key' => 'value']]; - - $feed = Feed::create([ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed', - 'type' => 'rss', - 'provider' => 'vrt', - 'language_id' => $language->id, - 'description' => 'Test description', - 'settings' => $settings, - 'is_active' => false - ]); - - $this->assertEquals('Test Feed', $feed->name); - $this->assertEquals('https://example.com/feed', $feed->url); - $this->assertEquals('rss', $feed->type); - $this->assertEquals($language->id, $feed->language_id); - $this->assertEquals('Test description', $feed->description); - $this->assertEquals($settings, $feed->settings); - $this->assertFalse($feed->is_active); - } - - public function test_feed_update(): void - { - $feed = Feed::factory()->create([ - 'name' => 'Original Name', - 'is_active' => true - ]); - - $feed->update([ - 'name' => 'Updated Name', - 'is_active' => false - ]); - - $feed->refresh(); - - $this->assertEquals('Updated Name', $feed->name); - $this->assertFalse($feed->is_active); - } - - public function test_feed_deletion(): void - { - $feed = Feed::factory()->create(); - $feedId = $feed->id; - - $feed->delete(); - - $this->assertDatabaseMissing('feeds', ['id' => $feedId]); - } - - public function test_feed_settings_can_be_empty_array(): void - { - $feed = Feed::factory()->create(['settings' => []]); - - $this->assertIsArray($feed->settings); - $this->assertEmpty($feed->settings); - } - - public function test_feed_settings_can_be_complex_structure(): void - { - $complexSettings = [ - 'parsing' => [ - 'selector' => 'article.post', - 'title_selector' => 'h1', - 'content_selector' => '.content' - ], - 'filters' => ['min_length' => 100], - 'schedule' => [ - 'enabled' => true, - 'interval' => 3600 - ] - ]; - - $feed = Feed::factory()->create(['settings' => $complexSettings]); - - $this->assertEquals($complexSettings, $feed->settings); - $this->assertEquals('article.post', $feed->settings['parsing']['selector']); - $this->assertTrue($feed->settings['schedule']['enabled']); - } - - public function test_feed_can_have_null_last_fetched_at(): void - { - $feed = Feed::factory()->create(['last_fetched_at' => null]); - - $this->assertNull($feed->last_fetched_at); - } - - public function test_feed_timestamps(): void - { - $feed = Feed::factory()->create(); - - $this->assertNotNull($feed->created_at); - $this->assertNotNull($feed->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->updated_at); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/KeywordTest.php b/tests/Unit/Models/KeywordTest.php deleted file mode 100644 index e8b4208..0000000 --- a/tests/Unit/Models/KeywordTest.php +++ /dev/null @@ -1,280 +0,0 @@ -assertEquals($fillableFields, $keyword->getFillable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test', - 'is_active' => '1' - ]); - - $this->assertIsBool($keyword->is_active); - $this->assertTrue($keyword->is_active); - - $keyword->update(['is_active' => '0']); - $keyword->refresh(); - - $this->assertIsBool($keyword->is_active); - $this->assertFalse($keyword->is_active); - } - - public function test_belongs_to_feed_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test keyword', - 'is_active' => true - ]); - - $this->assertInstanceOf(Feed::class, $keyword->feed); - $this->assertEquals($feed->id, $keyword->feed->id); - $this->assertEquals($feed->name, $keyword->feed->name); - } - - public function test_belongs_to_platform_channel_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test keyword', - 'is_active' => true - ]); - - $this->assertInstanceOf(PlatformChannel::class, $keyword->platformChannel); - $this->assertEquals($channel->id, $keyword->platformChannel->id); - $this->assertEquals($channel->name, $keyword->platformChannel->name); - } - - public function test_keyword_creation_with_factory(): void - { - $keyword = Keyword::factory()->create(); - - $this->assertInstanceOf(Keyword::class, $keyword); - $this->assertNotNull($keyword->feed_id); - $this->assertNotNull($keyword->platform_channel_id); - $this->assertIsString($keyword->keyword); - $this->assertIsBool($keyword->is_active); - } - - public function test_keyword_creation_with_explicit_values(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'Belgium', - 'is_active' => false - ]); - - $this->assertEquals($feed->id, $keyword->feed_id); - $this->assertEquals($channel->id, $keyword->platform_channel_id); - $this->assertEquals('Belgium', $keyword->keyword); - $this->assertFalse($keyword->is_active); - } - - public function test_keyword_update(): void - { - $keyword = Keyword::factory()->create([ - 'keyword' => 'original', - 'is_active' => true - ]); - - $keyword->update([ - 'keyword' => 'updated', - 'is_active' => false - ]); - - $keyword->refresh(); - - $this->assertEquals('updated', $keyword->keyword); - $this->assertFalse($keyword->is_active); - } - - public function test_keyword_deletion(): void - { - $keyword = Keyword::factory()->create(); - $keywordId = $keyword->id; - - $keyword->delete(); - - $this->assertDatabaseMissing('keywords', ['id' => $keywordId]); - } - - public function test_keyword_with_special_characters(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $specialKeywords = [ - 'België', // Accented characters - 'COVID-19', // Numbers and hyphens - 'U.S.A.', // Periods - 'keyword with spaces', - 'UPPERCASE', - 'lowercase', - 'MixedCase' - ]; - - foreach ($specialKeywords as $keywordText) { - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => $keywordText, - 'is_active' => true - ]); - - $this->assertEquals($keywordText, $keyword->keyword); - $this->assertDatabaseHas('keywords', [ - 'keyword' => $keywordText, - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id - ]); - } - } - - public function test_multiple_keywords_for_same_route(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword1 = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'keyword1', - 'is_active' => true - ]); - - $keyword2 = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'keyword2', - 'is_active' => false - ]); - - $this->assertDatabaseHas('keywords', [ - 'id' => $keyword1->id, - 'keyword' => 'keyword1', - 'is_active' => true - ]); - - $this->assertDatabaseHas('keywords', [ - 'id' => $keyword2->id, - 'keyword' => 'keyword2', - 'is_active' => false - ]); - } - - public function test_keyword_uniqueness_constraint(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - // Create first keyword - Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'unique_keyword', - 'is_active' => true - ]); - - // Attempt to create duplicate should fail - $this->expectException(\Illuminate\Database\QueryException::class); - - Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'unique_keyword', - 'is_active' => true - ]); - } - - public function test_same_keyword_different_routes_allowed(): void - { - $feed1 = Feed::factory()->create(); - $feed2 = Feed::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - // Same keyword for different routes should be allowed - $keyword1 = Keyword::create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel1->id, - 'keyword' => 'common_keyword', - 'is_active' => true - ]); - - $keyword2 = Keyword::create([ - 'feed_id' => $feed2->id, - 'platform_channel_id' => $channel2->id, - 'keyword' => 'common_keyword', - 'is_active' => true - ]); - - $this->assertDatabaseHas('keywords', ['id' => $keyword1->id]); - $this->assertDatabaseHas('keywords', ['id' => $keyword2->id]); - $this->assertNotEquals($keyword1->id, $keyword2->id); - } - - public function test_keyword_timestamps(): void - { - $keyword = Keyword::factory()->create(); - - $this->assertNotNull($keyword->created_at); - $this->assertNotNull($keyword->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $keyword->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $keyword->updated_at); - } - - public function test_keyword_default_active_state(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - // Create without specifying is_active - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test' - ]); - - // Refresh to get the actual database values including defaults - $keyword->refresh(); - - // Should default to true based on migration default - $this->assertIsBool($keyword->is_active); - $this->assertTrue($keyword->is_active); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/LanguageTest.php b/tests/Unit/Models/LanguageTest.php deleted file mode 100644 index f0a768f..0000000 --- a/tests/Unit/Models/LanguageTest.php +++ /dev/null @@ -1,324 +0,0 @@ -assertEquals($fillableFields, $language->getFillable()); - } - - public function test_table_name(): void - { - $language = new Language(); - - $this->assertEquals('languages', $language->getTable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $language = Language::factory()->create(['is_active' => '1']); - - $this->assertIsBool($language->is_active); - $this->assertTrue($language->is_active); - - $language->update(['is_active' => '0']); - $language->refresh(); - - $this->assertIsBool($language->is_active); - $this->assertFalse($language->is_active); - } - - public function test_belongs_to_many_platform_instances_relationship(): void - { - $language = Language::factory()->create(); - $instance1 = PlatformInstance::factory()->create(); - $instance2 = PlatformInstance::factory()->create(); - - // Attach with required platform_language_id - $language->platformInstances()->attach([ - $instance1->id => ['platform_language_id' => 1], - $instance2->id => ['platform_language_id' => 2] - ]); - - $instances = $language->platformInstances; - - $this->assertCount(2, $instances); - $this->assertTrue($instances->contains('id', $instance1->id)); - $this->assertTrue($instances->contains('id', $instance2->id)); - } - - public function test_has_many_platform_channels_relationship(): void - { - $language = Language::factory()->create(); - - $channel1 = PlatformChannel::factory()->create(['language_id' => $language->id]); - $channel2 = PlatformChannel::factory()->create(['language_id' => $language->id]); - - // Create channel for different language - $otherLanguage = Language::factory()->create(); - PlatformChannel::factory()->create(['language_id' => $otherLanguage->id]); - - $channels = $language->platformChannels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - $this->assertInstanceOf(PlatformChannel::class, $channels->first()); - } - - public function test_has_many_feeds_relationship(): void - { - $language = Language::factory()->create(); - - $feed1 = Feed::factory()->create(['language_id' => $language->id]); - $feed2 = Feed::factory()->create(['language_id' => $language->id]); - - // Create feed for different language - $otherLanguage = Language::factory()->create(); - Feed::factory()->create(['language_id' => $otherLanguage->id]); - - $feeds = $language->feeds; - - $this->assertCount(2, $feeds); - $this->assertTrue($feeds->contains('id', $feed1->id)); - $this->assertTrue($feeds->contains('id', $feed2->id)); - $this->assertInstanceOf(Feed::class, $feeds->first()); - } - - public function test_language_creation_with_factory(): void - { - $language = Language::factory()->create(); - - $this->assertInstanceOf(Language::class, $language); - $this->assertIsString($language->short_code); - $this->assertIsString($language->name); - $this->assertTrue($language->is_active); - } - - public function test_language_creation_with_explicit_values(): void - { - $language = Language::create([ - 'short_code' => 'fr', - 'name' => 'French', - 'native_name' => 'Français', - 'is_active' => false - ]); - - $this->assertEquals('fr', $language->short_code); - $this->assertEquals('French', $language->name); - $this->assertEquals('Français', $language->native_name); - $this->assertFalse($language->is_active); - } - - public function test_language_factory_states(): void - { - $inactiveLanguage = Language::factory()->inactive()->create(); - $this->assertFalse($inactiveLanguage->is_active); - - $englishLanguage = Language::factory()->english()->create(); - $this->assertEquals('en', $englishLanguage->short_code); - $this->assertEquals('English', $englishLanguage->name); - $this->assertEquals('English', $englishLanguage->native_name); - } - - public function test_language_update(): void - { - $language = Language::factory()->create([ - 'name' => 'Original Name', - 'is_active' => true - ]); - - $language->update([ - 'name' => 'Updated Name', - 'is_active' => false - ]); - - $language->refresh(); - - $this->assertEquals('Updated Name', $language->name); - $this->assertFalse($language->is_active); - } - - public function test_language_deletion(): void - { - $language = Language::factory()->create(); - $languageId = $language->id; - - $language->delete(); - - $this->assertDatabaseMissing('languages', ['id' => $languageId]); - } - - public function test_language_can_have_null_native_name(): void - { - $language = Language::factory()->create(['native_name' => null]); - - $this->assertNull($language->native_name); - } - - public function test_language_can_have_empty_native_name(): void - { - $language = Language::factory()->create(['native_name' => '']); - - $this->assertEquals('', $language->native_name); - } - - public function test_language_short_code_variations(): void - { - $shortCodes = ['en', 'fr', 'es', 'de', 'zh', 'pt', 'nl', 'it']; - - foreach ($shortCodes as $code) { - $language = Language::factory()->create(['short_code' => $code]); - $this->assertEquals($code, $language->short_code); - } - } - - public function test_language_timestamps(): void - { - $language = Language::factory()->create(); - - $this->assertNotNull($language->created_at); - $this->assertNotNull($language->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $language->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $language->updated_at); - } - - public function test_language_can_have_multiple_platform_instances(): void - { - $language = Language::factory()->create(); - $instance1 = PlatformInstance::factory()->create(); - $instance2 = PlatformInstance::factory()->create(); - $instance3 = PlatformInstance::factory()->create(); - - // Attach with required platform_language_id values - $language->platformInstances()->attach([ - $instance1->id => ['platform_language_id' => 1], - $instance2->id => ['platform_language_id' => 2], - $instance3->id => ['platform_language_id' => 3] - ]); - - $instances = $language->platformInstances; - - $this->assertCount(3, $instances); - $this->assertTrue($instances->contains('id', $instance1->id)); - $this->assertTrue($instances->contains('id', $instance2->id)); - $this->assertTrue($instances->contains('id', $instance3->id)); - } - - public function test_language_platform_instances_relationship_is_empty_by_default(): void - { - $language = Language::factory()->create(); - - $this->assertCount(0, $language->platformInstances); - } - - public function test_language_platform_channels_relationship_is_empty_by_default(): void - { - $language = Language::factory()->create(); - - $this->assertCount(0, $language->platformChannels); - } - - public function test_language_feeds_relationship_is_empty_by_default(): void - { - $language = Language::factory()->create(); - - $this->assertCount(0, $language->feeds); - } - - public function test_multiple_languages_with_same_name_different_regions(): void - { - $englishUS = Language::factory()->create([ - 'short_code' => 'en-US', - 'name' => 'English (United States)', - 'native_name' => 'English' - ]); - - $englishGB = Language::factory()->create([ - 'short_code' => 'en-GB', - 'name' => 'English (United Kingdom)', - 'native_name' => 'English' - ]); - - $this->assertEquals('English', $englishUS->native_name); - $this->assertEquals('English', $englishGB->native_name); - $this->assertNotEquals($englishUS->short_code, $englishGB->short_code); - $this->assertNotEquals($englishUS->name, $englishGB->name); - } - - public function test_language_with_complex_native_name(): void - { - $complexLanguages = [ - ['short_code' => 'zh-CN', 'name' => 'Chinese (Simplified)', 'native_name' => '简体中文'], - ['short_code' => 'zh-TW', 'name' => 'Chinese (Traditional)', 'native_name' => '繁體中文'], - ['short_code' => 'ar', 'name' => 'Arabic', 'native_name' => 'العربية'], - ['short_code' => 'ru', 'name' => 'Russian', 'native_name' => 'Русский'], - ['short_code' => 'ja', 'name' => 'Japanese', 'native_name' => '日本語'], - ]; - - foreach ($complexLanguages as $langData) { - $language = Language::factory()->create($langData); - - $this->assertEquals($langData['short_code'], $language->short_code); - $this->assertEquals($langData['name'], $language->name); - $this->assertEquals($langData['native_name'], $language->native_name); - } - } - - public function test_language_active_and_inactive_states(): void - { - $activeLanguage = Language::factory()->create(['is_active' => true]); - $inactiveLanguage = Language::factory()->create(['is_active' => false]); - - $this->assertTrue($activeLanguage->is_active); - $this->assertFalse($inactiveLanguage->is_active); - } - - public function test_language_relationships_maintain_referential_integrity(): void - { - $language = Language::factory()->create(); - - // Create related models - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); - $feed = Feed::factory()->create(['language_id' => $language->id]); - - // Attach instance - $language->platformInstances()->attach($instance->id, [ - 'platform_language_id' => 1, - 'is_default' => true - ]); - - // Verify all relationships work - $this->assertCount(1, $language->platformInstances); - $this->assertCount(1, $language->platformChannels); - $this->assertCount(1, $language->feeds); - - $this->assertEquals($language->id, $channel->language_id); - $this->assertEquals($language->id, $feed->language_id); - } - - public function test_language_factory_unique_constraints(): void - { - // The factory should generate unique short codes - $language1 = Language::factory()->create(); - $language2 = Language::factory()->create(); - - $this->assertNotEquals($language1->short_code, $language2->short_code); - $this->assertNotEquals($language1->name, $language2->name); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/PlatformAccountTest.php b/tests/Unit/Models/PlatformAccountTest.php deleted file mode 100644 index 7e71501..0000000 --- a/tests/Unit/Models/PlatformAccountTest.php +++ /dev/null @@ -1,417 +0,0 @@ -assertEquals($fillableFields, $account->getFillable()); - } - - public function test_table_name(): void - { - $account = new PlatformAccount(); - - $this->assertEquals('platform_accounts', $account->getTable()); - } - - public function test_casts_platform_to_enum(): void - { - $account = PlatformAccount::factory()->create(['platform' => PlatformEnum::LEMMY]); - - $this->assertInstanceOf(PlatformEnum::class, $account->platform); - $this->assertEquals(PlatformEnum::LEMMY, $account->platform); - $this->assertEquals('lemmy', $account->platform->value); - } - - public function test_casts_settings_to_array(): void - { - $settings = ['key1' => 'value1', 'nested' => ['key2' => 'value2']]; - - $account = PlatformAccount::factory()->create(['settings' => $settings]); - - $this->assertIsArray($account->settings); - $this->assertEquals($settings, $account->settings); - } - - public function test_casts_is_active_to_boolean(): void - { - $account = PlatformAccount::factory()->create(['is_active' => '1']); - - $this->assertIsBool($account->is_active); - $this->assertTrue($account->is_active); - - $account->update(['is_active' => '0']); - $account->refresh(); - - $this->assertIsBool($account->is_active); - $this->assertFalse($account->is_active); - } - - public function test_casts_last_tested_at_to_datetime(): void - { - $timestamp = now()->subHours(2); - $account = PlatformAccount::factory()->create(['last_tested_at' => $timestamp]); - - $this->assertInstanceOf(\Carbon\Carbon::class, $account->last_tested_at); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $account->last_tested_at->format('Y-m-d H:i:s')); - } - - public function test_password_encryption_and_decryption(): void - { - $plainPassword = 'my-secret-password'; - - $account = PlatformAccount::factory()->create(['password' => $plainPassword]); - - // Password should be decrypted when accessing - $this->assertEquals($plainPassword, $account->password); - - // But encrypted in the database - $this->assertNotEquals($plainPassword, $account->getAttributes()['password']); - $this->assertNotNull($account->getAttributes()['password']); - } - - public function test_password_with_specific_value(): void - { - $password = 'specific-test-password'; - $account = PlatformAccount::factory()->create(['password' => $password]); - - $this->assertEquals($password, $account->password); - $this->assertNotEquals($password, $account->getAttributes()['password']); - } - - public function test_password_encryption_is_different_each_time(): void - { - $password = 'same-password'; - $account1 = PlatformAccount::factory()->create(['password' => $password]); - $account2 = PlatformAccount::factory()->create(['password' => $password]); - - $this->assertEquals($password, $account1->password); - $this->assertEquals($password, $account2->password); - $this->assertNotEquals($account1->getAttributes()['password'], $account2->getAttributes()['password']); - } - - - public function test_password_decryption_handles_corruption(): void - { - $account = PlatformAccount::factory()->create(); - $originalPassword = $account->password; - - // Since the password attribute has special handling, this test verifies the basic functionality - $this->assertNotNull($originalPassword); - $this->assertIsString($originalPassword); - } - - public function test_get_active_static_method(): void - { - // Create active and inactive accounts - $activeAccount1 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => true - ]); - - $activeAccount2 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => true - ]); - - $inactiveAccount = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => false - ]); - - $activeAccounts = PlatformAccount::getActive(PlatformEnum::LEMMY); - - $this->assertCount(2, $activeAccounts); - $this->assertTrue($activeAccounts->contains('id', $activeAccount1->id)); - $this->assertTrue($activeAccounts->contains('id', $activeAccount2->id)); - $this->assertFalse($activeAccounts->contains('id', $inactiveAccount->id)); - } - - public function test_set_as_active_method(): void - { - // Create multiple accounts for same platform - $account1 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => true - ]); - - $account2 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => true - ]); - - $account3 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => false - ]); - - // Set account3 as active - $account3->setAsActive(); - - // Refresh all accounts - $account1->refresh(); - $account2->refresh(); - $account3->refresh(); - - // Only account3 should be active - $this->assertFalse($account1->is_active); - $this->assertFalse($account2->is_active); - $this->assertTrue($account3->is_active); - } - - public function test_belongs_to_many_channels_relationship(): void - { - $account = PlatformAccount::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - // Attach channels with pivot data - $account->channels()->attach($channel1->id, [ - 'is_active' => true, - 'priority' => 100 - ]); - - $account->channels()->attach($channel2->id, [ - 'is_active' => false, - 'priority' => 50 - ]); - - $channels = $account->channels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - - // Test pivot data - $channel1FromRelation = $channels->find($channel1->id); - $this->assertEquals(1, $channel1FromRelation->pivot->is_active); - $this->assertEquals(100, $channel1FromRelation->pivot->priority); - - $channel2FromRelation = $channels->find($channel2->id); - $this->assertEquals(0, $channel2FromRelation->pivot->is_active); - $this->assertEquals(50, $channel2FromRelation->pivot->priority); - } - - public function test_active_channels_relationship(): void - { - $account = PlatformAccount::factory()->create(); - $activeChannel1 = PlatformChannel::factory()->create(); - $activeChannel2 = PlatformChannel::factory()->create(); - $inactiveChannel = PlatformChannel::factory()->create(); - - // Attach channels - $account->channels()->attach($activeChannel1->id, [ - 'is_active' => true, - 'priority' => 100 - ]); - - $account->channels()->attach($activeChannel2->id, [ - 'is_active' => true, - 'priority' => 200 - ]); - - $account->channels()->attach($inactiveChannel->id, [ - 'is_active' => false, - 'priority' => 150 - ]); - - $activeChannels = $account->activeChannels; - - $this->assertCount(2, $activeChannels); - $this->assertTrue($activeChannels->contains('id', $activeChannel1->id)); - $this->assertTrue($activeChannels->contains('id', $activeChannel2->id)); - $this->assertFalse($activeChannels->contains('id', $inactiveChannel->id)); - - // Test ordering by priority descending - $channelIds = $activeChannels->pluck('id')->toArray(); - $this->assertEquals($activeChannel2->id, $channelIds[0]); // Priority 200 - $this->assertEquals($activeChannel1->id, $channelIds[1]); // Priority 100 - } - - public function test_account_creation_with_factory(): void - { - $account = PlatformAccount::factory()->create(); - - $this->assertInstanceOf(PlatformAccount::class, $account); - $this->assertInstanceOf(PlatformEnum::class, $account->platform); - $this->assertEquals(PlatformEnum::LEMMY, $account->platform); - $this->assertIsString($account->instance_url); - $this->assertIsString($account->username); - $this->assertEquals('test-password', $account->password); - $this->assertIsBool($account->is_active); - $this->assertTrue($account->is_active); - $this->assertEquals('untested', $account->status); - $this->assertIsArray($account->settings); - } - - public function test_account_creation_with_explicit_values(): void - { - $settings = ['custom' => 'value', 'nested' => ['key' => 'value']]; - $timestamp = now()->subHours(1); - - $account = PlatformAccount::create([ - 'platform' => PlatformEnum::LEMMY, - 'instance_url' => 'https://lemmy.example.com', - 'username' => 'testuser', - 'password' => 'secret123', - 'settings' => $settings, - 'is_active' => false, - 'last_tested_at' => $timestamp, - 'status' => 'working' - ]); - - $this->assertEquals(PlatformEnum::LEMMY, $account->platform); - $this->assertEquals('https://lemmy.example.com', $account->instance_url); - $this->assertEquals('testuser', $account->username); - $this->assertEquals('secret123', $account->password); - $this->assertEquals($settings, $account->settings); - $this->assertFalse($account->is_active); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $account->last_tested_at->format('Y-m-d H:i:s')); - $this->assertEquals('working', $account->status); - } - - public function test_account_factory_states(): void - { - $inactiveAccount = PlatformAccount::factory()->inactive()->create(); - $this->assertFalse($inactiveAccount->is_active); - - $testedAccount = PlatformAccount::factory()->tested()->create(); - $this->assertNotNull($testedAccount->last_tested_at); - $this->assertEquals('working', $testedAccount->status); - - $failedAccount = PlatformAccount::factory()->failed()->create(); - $this->assertNotNull($failedAccount->last_tested_at); - $this->assertEquals('failed', $failedAccount->status); - } - - public function test_account_update(): void - { - $account = PlatformAccount::factory()->create([ - 'username' => 'original_user', - 'is_active' => true - ]); - - $account->update([ - 'username' => 'updated_user', - 'is_active' => false - ]); - - $account->refresh(); - - $this->assertEquals('updated_user', $account->username); - $this->assertFalse($account->is_active); - } - - public function test_account_deletion(): void - { - $account = PlatformAccount::factory()->create(); - $accountId = $account->id; - - $account->delete(); - - $this->assertDatabaseMissing('platform_accounts', ['id' => $accountId]); - } - - public function test_account_settings_can_be_empty_array(): void - { - $account = PlatformAccount::factory()->create(['settings' => []]); - - $this->assertIsArray($account->settings); - $this->assertEmpty($account->settings); - } - - public function test_account_settings_can_be_complex_structure(): void - { - $complexSettings = [ - 'authentication' => [ - 'method' => 'jwt', - 'timeout' => 30 - ], - 'features' => ['posting', 'commenting'], - 'rate_limits' => [ - 'posts_per_hour' => 10, - 'comments_per_hour' => 50 - ] - ]; - - $account = PlatformAccount::factory()->create(['settings' => $complexSettings]); - - $this->assertEquals($complexSettings, $account->settings); - $this->assertEquals('jwt', $account->settings['authentication']['method']); - $this->assertEquals(['posting', 'commenting'], $account->settings['features']); - } - - public function test_account_can_have_null_last_tested_at(): void - { - $account = PlatformAccount::factory()->create(['last_tested_at' => null]); - - $this->assertNull($account->last_tested_at); - } - - public function test_account_timestamps(): void - { - $account = PlatformAccount::factory()->create(); - - $this->assertNotNull($account->created_at); - $this->assertNotNull($account->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $account->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $account->updated_at); - } - - public function test_account_can_have_multiple_channels_with_different_priorities(): void - { - $account = PlatformAccount::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - $channel3 = PlatformChannel::factory()->create(); - - // Attach channels with different priorities - $account->channels()->attach([ - $channel1->id => ['is_active' => true, 'priority' => 300], - $channel2->id => ['is_active' => true, 'priority' => 100], - $channel3->id => ['is_active' => false, 'priority' => 200] - ]); - - $allChannels = $account->channels; - $activeChannels = $account->activeChannels; - - $this->assertCount(3, $allChannels); - $this->assertCount(2, $activeChannels); - - // Test that we can access pivot data - foreach ($allChannels as $channel) { - $this->assertNotNull($channel->pivot->priority); - $this->assertIsInt($channel->pivot->is_active); - } - } - - public function test_password_withoutObjectCaching_prevents_caching(): void - { - $account = PlatformAccount::factory()->create(['password' => 'original']); - - // Access password to potentially cache it - $originalPassword = $account->password; - $this->assertEquals('original', $originalPassword); - - // Update password directly in database - $account->update(['password' => 'updated']); - - // Since withoutObjectCaching is used, the new value should be retrieved - $account->refresh(); - $this->assertEquals('updated', $account->password); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/PlatformChannelTest.php b/tests/Unit/Models/PlatformChannelTest.php deleted file mode 100644 index 39e5e5c..0000000 --- a/tests/Unit/Models/PlatformChannelTest.php +++ /dev/null @@ -1,338 +0,0 @@ -assertEquals($fillableFields, $channel->getFillable()); - } - - public function test_table_name(): void - { - $channel = new PlatformChannel(); - - $this->assertEquals('platform_channels', $channel->getTable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $channel = PlatformChannel::factory()->create(['is_active' => '1']); - - $this->assertIsBool($channel->is_active); - $this->assertTrue($channel->is_active); - - $channel->update(['is_active' => '0']); - $channel->refresh(); - - $this->assertIsBool($channel->is_active); - $this->assertFalse($channel->is_active); - } - - public function test_belongs_to_platform_instance_relationship(): void - { - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - - $this->assertInstanceOf(PlatformInstance::class, $channel->platformInstance); - $this->assertEquals($instance->id, $channel->platformInstance->id); - $this->assertEquals($instance->name, $channel->platformInstance->name); - } - - public function test_belongs_to_language_relationship(): void - { - $language = Language::factory()->create(); - $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); - - $this->assertInstanceOf(Language::class, $channel->language); - $this->assertEquals($language->id, $channel->language->id); - $this->assertEquals($language->name, $channel->language->name); - } - - public function test_belongs_to_many_platform_accounts_relationship(): void - { - $channel = PlatformChannel::factory()->create(); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Attach accounts with pivot data - $channel->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 100 - ]); - - $channel->platformAccounts()->attach($account2->id, [ - 'is_active' => false, - 'priority' => 50 - ]); - - $accounts = $channel->platformAccounts; - - $this->assertCount(2, $accounts); - $this->assertTrue($accounts->contains('id', $account1->id)); - $this->assertTrue($accounts->contains('id', $account2->id)); - - // Test pivot data - $account1FromRelation = $accounts->find($account1->id); - $this->assertEquals(1, $account1FromRelation->pivot->is_active); - $this->assertEquals(100, $account1FromRelation->pivot->priority); - - $account2FromRelation = $accounts->find($account2->id); - $this->assertEquals(0, $account2FromRelation->pivot->is_active); - $this->assertEquals(50, $account2FromRelation->pivot->priority); - } - - public function test_active_platform_accounts_relationship(): void - { - $channel = PlatformChannel::factory()->create(); - $activeAccount1 = PlatformAccount::factory()->create(); - $activeAccount2 = PlatformAccount::factory()->create(); - $inactiveAccount = PlatformAccount::factory()->create(); - - // Attach accounts - $channel->platformAccounts()->attach($activeAccount1->id, [ - 'is_active' => true, - 'priority' => 100 - ]); - - $channel->platformAccounts()->attach($activeAccount2->id, [ - 'is_active' => true, - 'priority' => 200 - ]); - - $channel->platformAccounts()->attach($inactiveAccount->id, [ - 'is_active' => false, - 'priority' => 150 - ]); - - $activeAccounts = $channel->activePlatformAccounts; - - $this->assertCount(2, $activeAccounts); - $this->assertTrue($activeAccounts->contains('id', $activeAccount1->id)); - $this->assertTrue($activeAccounts->contains('id', $activeAccount2->id)); - $this->assertFalse($activeAccounts->contains('id', $inactiveAccount->id)); - } - - public function test_full_name_attribute(): void - { - $instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.example.com']); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'name' => 'technology' - ]); - - $this->assertEquals('https://lemmy.example.com/c/technology', $channel->full_name); - } - - public function test_belongs_to_many_feeds_relationship(): void - { - $channel = PlatformChannel::factory()->create(); - $feed1 = Feed::factory()->create(); - $feed2 = Feed::factory()->create(); - - // Create routes (which act as pivot records) - Route::create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed2->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false, - 'priority' => 50 - ]); - - $feeds = $channel->feeds; - - $this->assertCount(2, $feeds); - $this->assertTrue($feeds->contains('id', $feed1->id)); - $this->assertTrue($feeds->contains('id', $feed2->id)); - - // Test pivot data - $feed1FromRelation = $feeds->find($feed1->id); - $this->assertEquals(1, $feed1FromRelation->pivot->is_active); - $this->assertEquals(100, $feed1FromRelation->pivot->priority); - } - - public function test_active_feeds_relationship(): void - { - $channel = PlatformChannel::factory()->create(); - $activeFeed1 = Feed::factory()->create(); - $activeFeed2 = Feed::factory()->create(); - $inactiveFeed = Feed::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $activeFeed1->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $activeFeed2->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 200 - ]); - - Route::create([ - 'feed_id' => $inactiveFeed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false, - 'priority' => 150 - ]); - - $activeFeeds = $channel->activeFeeds; - - $this->assertCount(2, $activeFeeds); - $this->assertTrue($activeFeeds->contains('id', $activeFeed1->id)); - $this->assertTrue($activeFeeds->contains('id', $activeFeed2->id)); - $this->assertFalse($activeFeeds->contains('id', $inactiveFeed->id)); - - // Test ordering by priority descending - $feedIds = $activeFeeds->pluck('id')->toArray(); - $this->assertEquals($activeFeed2->id, $feedIds[0]); // Priority 200 - $this->assertEquals($activeFeed1->id, $feedIds[1]); // Priority 100 - } - - public function test_channel_creation_with_factory(): void - { - $channel = PlatformChannel::factory()->create(); - - $this->assertInstanceOf(PlatformChannel::class, $channel); - $this->assertNotNull($channel->platform_instance_id); - $this->assertIsString($channel->name); - $this->assertIsString($channel->channel_id); - $this->assertIsBool($channel->is_active); - } - - public function test_channel_creation_with_explicit_values(): void - { - $instance = PlatformInstance::factory()->create(); - $language = Language::factory()->create(); - - $channel = PlatformChannel::create([ - 'platform_instance_id' => $instance->id, - 'name' => 'test_channel', - 'display_name' => 'Test Channel', - 'channel_id' => 'channel_123', - 'description' => 'A test channel', - 'language_id' => $language->id, - 'is_active' => false - ]); - - $this->assertEquals($instance->id, $channel->platform_instance_id); - $this->assertEquals('test_channel', $channel->name); - $this->assertEquals('Test Channel', $channel->display_name); - $this->assertEquals('channel_123', $channel->channel_id); - $this->assertEquals('A test channel', $channel->description); - $this->assertEquals($language->id, $channel->language_id); - $this->assertFalse($channel->is_active); - } - - public function test_channel_update(): void - { - $channel = PlatformChannel::factory()->create([ - 'name' => 'original_name', - 'is_active' => true - ]); - - $channel->update([ - 'name' => 'updated_name', - 'is_active' => false - ]); - - $channel->refresh(); - - $this->assertEquals('updated_name', $channel->name); - $this->assertFalse($channel->is_active); - } - - public function test_channel_deletion(): void - { - $channel = PlatformChannel::factory()->create(); - $channelId = $channel->id; - - $channel->delete(); - - $this->assertDatabaseMissing('platform_channels', ['id' => $channelId]); - } - - public function test_channel_with_display_name(): void - { - $channel = PlatformChannel::factory()->create([ - 'name' => 'tech', - 'display_name' => 'Technology Discussion' - ]); - - $this->assertEquals('tech', $channel->name); - $this->assertEquals('Technology Discussion', $channel->display_name); - } - - public function test_channel_without_display_name(): void - { - $channel = PlatformChannel::factory()->create([ - 'name' => 'general', - 'display_name' => 'General' - ]); - - $this->assertEquals('general', $channel->name); - $this->assertEquals('General', $channel->display_name); - } - - public function test_channel_timestamps(): void - { - $channel = PlatformChannel::factory()->create(); - - $this->assertNotNull($channel->created_at); - $this->assertNotNull($channel->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $channel->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $channel->updated_at); - } - - public function test_channel_can_have_multiple_accounts_with_different_priorities(): void - { - $channel = PlatformChannel::factory()->create(); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - $account3 = PlatformAccount::factory()->create(); - - // Attach accounts with different priorities - $channel->platformAccounts()->attach([ - $account1->id => ['is_active' => true, 'priority' => 300], - $account2->id => ['is_active' => true, 'priority' => 100], - $account3->id => ['is_active' => false, 'priority' => 200] - ]); - - $allAccounts = $channel->platformAccounts; - $activeAccounts = $channel->activePlatformAccounts; - - $this->assertCount(3, $allAccounts); - $this->assertCount(2, $activeAccounts); - - // Test that we can access pivot data - foreach ($allAccounts as $account) { - $this->assertNotNull($account->pivot->priority); - $this->assertIsInt($account->pivot->is_active); - } - } -} \ No newline at end of file diff --git a/tests/Unit/Models/PlatformInstanceTest.php b/tests/Unit/Models/PlatformInstanceTest.php deleted file mode 100644 index 9463493..0000000 --- a/tests/Unit/Models/PlatformInstanceTest.php +++ /dev/null @@ -1,325 +0,0 @@ -assertEquals($fillableFields, $instance->getFillable()); - } - - public function test_table_name(): void - { - $instance = new PlatformInstance(); - - $this->assertEquals('platform_instances', $instance->getTable()); - } - - public function test_casts_platform_to_enum(): void - { - $instance = PlatformInstance::factory()->create(['platform' => PlatformEnum::LEMMY]); - - $this->assertInstanceOf(PlatformEnum::class, $instance->platform); - $this->assertEquals(PlatformEnum::LEMMY, $instance->platform); - $this->assertEquals('lemmy', $instance->platform->value); - } - - public function test_casts_is_active_to_boolean(): void - { - $instance = PlatformInstance::factory()->create(['is_active' => '1']); - - $this->assertIsBool($instance->is_active); - $this->assertTrue($instance->is_active); - - $instance->update(['is_active' => '0']); - $instance->refresh(); - - $this->assertIsBool($instance->is_active); - $this->assertFalse($instance->is_active); - } - - public function test_has_many_channels_relationship(): void - { - $instance = PlatformInstance::factory()->create(); - - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - - // Create channel for different instance - $otherInstance = PlatformInstance::factory()->create(); - PlatformChannel::factory()->create(['platform_instance_id' => $otherInstance->id]); - - $channels = $instance->channels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - $this->assertInstanceOf(PlatformChannel::class, $channels->first()); - } - - public function test_belongs_to_many_languages_relationship(): void - { - $instance = PlatformInstance::factory()->create(); - $language1 = Language::factory()->create(); - $language2 = Language::factory()->create(); - - // Attach languages with pivot data - $instance->languages()->attach($language1->id, [ - 'platform_language_id' => 1, - 'is_default' => true - ]); - - $instance->languages()->attach($language2->id, [ - 'platform_language_id' => 2, - 'is_default' => false - ]); - - $languages = $instance->languages; - - $this->assertCount(2, $languages); - $this->assertTrue($languages->contains('id', $language1->id)); - $this->assertTrue($languages->contains('id', $language2->id)); - - // Test pivot data - $language1FromRelation = $languages->find($language1->id); - $this->assertEquals(1, $language1FromRelation->pivot->platform_language_id); - $this->assertEquals(1, $language1FromRelation->pivot->is_default); // Database returns 1 for true - - $language2FromRelation = $languages->find($language2->id); - $this->assertEquals(2, $language2FromRelation->pivot->platform_language_id); - $this->assertEquals(0, $language2FromRelation->pivot->is_default); // Database returns 0 for false - } - - public function test_find_by_url_static_method(): void - { - $url = 'https://lemmy.world'; - - $instance1 = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => $url - ]); - - // Create instance with different URL - PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://lemmy.ml' - ]); - - $foundInstance = PlatformInstance::findByUrl(PlatformEnum::LEMMY, $url); - - $this->assertNotNull($foundInstance); - $this->assertEquals($instance1->id, $foundInstance->id); - $this->assertEquals($url, $foundInstance->url); - $this->assertEquals(PlatformEnum::LEMMY, $foundInstance->platform); - } - - public function test_find_by_url_returns_null_when_not_found(): void - { - $foundInstance = PlatformInstance::findByUrl(PlatformEnum::LEMMY, 'https://nonexistent.lemmy'); - - $this->assertNull($foundInstance); - } - - public function test_find_by_url_filters_by_platform(): void - { - $url = 'https://example.com'; - - // Create instance with same URL but different platform won't be found - PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => $url - ]); - - // Since we only have LEMMY in the enum, this test demonstrates the filtering logic - $foundInstance = PlatformInstance::findByUrl(PlatformEnum::LEMMY, $url); - $this->assertNotNull($foundInstance); - } - - public function test_instance_creation_with_factory(): void - { - $instance = PlatformInstance::factory()->create(); - - $this->assertInstanceOf(PlatformInstance::class, $instance); - $this->assertEquals('lemmy', $instance->platform->value); - $this->assertIsString($instance->name); - $this->assertIsString($instance->url); - $this->assertTrue($instance->is_active); - } - - public function test_instance_creation_with_explicit_values(): void - { - $instance = PlatformInstance::create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://lemmy.world', - 'name' => 'Lemmy World', - 'description' => 'A general purpose Lemmy instance', - 'is_active' => false - ]); - - $this->assertEquals(PlatformEnum::LEMMY, $instance->platform); - $this->assertEquals('https://lemmy.world', $instance->url); - $this->assertEquals('Lemmy World', $instance->name); - $this->assertEquals('A general purpose Lemmy instance', $instance->description); - $this->assertFalse($instance->is_active); - } - - public function test_instance_factory_states(): void - { - $inactiveInstance = PlatformInstance::factory()->inactive()->create(); - $this->assertFalse($inactiveInstance->is_active); - - $lemmyInstance = PlatformInstance::factory()->lemmy()->create(); - $this->assertEquals(PlatformEnum::LEMMY, $lemmyInstance->platform); - $this->assertStringStartsWith('Lemmy ', $lemmyInstance->name); - $this->assertStringStartsWith('https://lemmy.', $lemmyInstance->url); - } - - public function test_instance_update(): void - { - $instance = PlatformInstance::factory()->create([ - 'name' => 'Original Name', - 'is_active' => true - ]); - - $instance->update([ - 'name' => 'Updated Name', - 'is_active' => false - ]); - - $instance->refresh(); - - $this->assertEquals('Updated Name', $instance->name); - $this->assertFalse($instance->is_active); - } - - public function test_instance_deletion(): void - { - $instance = PlatformInstance::factory()->create(); - $instanceId = $instance->id; - - $instance->delete(); - - $this->assertDatabaseMissing('platform_instances', ['id' => $instanceId]); - } - - public function test_instance_can_have_null_description(): void - { - $instance = PlatformInstance::factory()->create(['description' => null]); - - $this->assertNull($instance->description); - } - - public function test_instance_can_have_empty_description(): void - { - $instance = PlatformInstance::factory()->create(['description' => '']); - - $this->assertEquals('', $instance->description); - } - - public function test_instance_url_validation(): void - { - $validUrls = [ - 'https://lemmy.world', - 'https://lemmy.ml', - 'https://beehaw.org', - 'http://localhost:8080', - ]; - - foreach ($validUrls as $url) { - $instance = PlatformInstance::factory()->create(['url' => $url]); - $this->assertEquals($url, $instance->url); - } - } - - public function test_instance_timestamps(): void - { - $instance = PlatformInstance::factory()->create(); - - $this->assertNotNull($instance->created_at); - $this->assertNotNull($instance->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $instance->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $instance->updated_at); - } - - public function test_instance_can_have_multiple_languages(): void - { - $instance = PlatformInstance::factory()->create(); - $language1 = Language::factory()->create(); - $language2 = Language::factory()->create(); - $language3 = Language::factory()->create(); - - // Attach multiple languages with different pivot data - $instance->languages()->attach([ - $language1->id => ['platform_language_id' => 1, 'is_default' => true], - $language2->id => ['platform_language_id' => 2, 'is_default' => false], - $language3->id => ['platform_language_id' => 3, 'is_default' => false] - ]); - - $languages = $instance->languages; - - $this->assertCount(3, $languages); - - // Test that we can access pivot data - foreach ($languages as $language) { - $this->assertNotNull($language->pivot->platform_language_id); - $this->assertContains($language->pivot->is_default, [0, 1, true, false]); // Can be int or bool - } - - // Only one should be default - $defaultLanguages = $languages->filter(fn($lang) => $lang->pivot->is_default); - $this->assertCount(1, $defaultLanguages); - } - - public function test_instance_channels_relationship_is_empty_by_default(): void - { - $instance = PlatformInstance::factory()->create(); - - $this->assertCount(0, $instance->channels); - } - - public function test_instance_languages_relationship_is_empty_by_default(): void - { - $instance = PlatformInstance::factory()->create(); - - $this->assertCount(0, $instance->languages); - } - - public function test_multiple_instances_with_same_platform(): void - { - $instance1 = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'name' => 'Lemmy World' - ]); - - $instance2 = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'name' => 'Lemmy ML' - ]); - - $this->assertEquals(PlatformEnum::LEMMY, $instance1->platform); - $this->assertEquals(PlatformEnum::LEMMY, $instance2->platform); - $this->assertNotEquals($instance1->id, $instance2->id); - $this->assertNotEquals($instance1->name, $instance2->name); - } - - public function test_instance_platform_enum_string_value(): void - { - $instance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - - $this->assertEquals('lemmy', $instance->platform->value); - $this->assertEquals(PlatformEnum::LEMMY, $instance->platform); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/RouteTest.php b/tests/Unit/Models/RouteTest.php deleted file mode 100644 index af5ff16..0000000 --- a/tests/Unit/Models/RouteTest.php +++ /dev/null @@ -1,261 +0,0 @@ -assertEquals($fillableFields, $route->getFillable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => '1', - 'priority' => 50 - ]); - - $this->assertIsBool($route->is_active); - $this->assertTrue($route->is_active); - - $route->update(['is_active' => '0']); - $route->refresh(); - - $this->assertIsBool($route->is_active); - $this->assertFalse($route->is_active); - } - - public function test_primary_key_configuration(): void - { - $route = new Route(); - - $this->assertNull($route->getKeyName()); - $this->assertFalse($route->getIncrementing()); - } - - public function test_table_name(): void - { - $route = new Route(); - - $this->assertEquals('routes', $route->getTable()); - } - - public function test_belongs_to_feed_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - $this->assertInstanceOf(Feed::class, $route->feed); - $this->assertEquals($feed->id, $route->feed->id); - $this->assertEquals($feed->name, $route->feed->name); - } - - public function test_belongs_to_platform_channel_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - $this->assertInstanceOf(PlatformChannel::class, $route->platformChannel); - $this->assertEquals($channel->id, $route->platformChannel->id); - $this->assertEquals($channel->name, $route->platformChannel->name); - } - - public function test_has_many_keywords_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Create keywords for this route - $keyword1 = Keyword::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test1' - ]); - - $keyword2 = Keyword::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test2' - ]); - - // Create keyword for different route (should not be included) - $otherFeed = Feed::factory()->create(); - Keyword::factory()->create([ - 'feed_id' => $otherFeed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'other' - ]); - - $keywords = $route->keywords; - - $this->assertCount(2, $keywords); - $this->assertTrue($keywords->contains('id', $keyword1->id)); - $this->assertTrue($keywords->contains('id', $keyword2->id)); - $this->assertInstanceOf(Keyword::class, $keywords->first()); - } - - public function test_keywords_relationship_filters_by_feed_and_channel(): void - { - $feed1 = Feed::factory()->create(); - $feed2 = Feed::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Create keyword for this exact route - $matchingKeyword = Keyword::factory()->create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel1->id, - 'keyword' => 'matching' - ]); - - // Create keyword for same feed but different channel - Keyword::factory()->create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel2->id, - 'keyword' => 'different_channel' - ]); - - // Create keyword for same channel but different feed - Keyword::factory()->create([ - 'feed_id' => $feed2->id, - 'platform_channel_id' => $channel1->id, - 'keyword' => 'different_feed' - ]); - - $keywords = $route->keywords; - - $this->assertCount(1, $keywords); - $this->assertEquals($matchingKeyword->id, $keywords->first()->id); - $this->assertEquals('matching', $keywords->first()->keyword); - } - - public function test_route_creation_with_factory(): void - { - $route = Route::factory()->create(); - - $this->assertInstanceOf(Route::class, $route); - $this->assertNotNull($route->feed_id); - $this->assertNotNull($route->platform_channel_id); - $this->assertIsBool($route->is_active); - $this->assertIsInt($route->priority); - } - - public function test_route_creation_with_explicit_values(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false, - 'priority' => 75 - ]); - - $this->assertEquals($feed->id, $route->feed_id); - $this->assertEquals($channel->id, $route->platform_channel_id); - $this->assertFalse($route->is_active); - $this->assertEquals(75, $route->priority); - } - - public function test_route_update(): void - { - $route = Route::factory()->create([ - 'is_active' => true, - 'priority' => 50 - ]); - - $route->update([ - 'is_active' => false, - 'priority' => 25 - ]); - - $route->refresh(); - - $this->assertFalse($route->is_active); - $this->assertEquals(25, $route->priority); - } - - public function test_route_with_multiple_keywords_active_and_inactive(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - Keyword::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'active_keyword', - 'is_active' => true - ]); - - Keyword::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'inactive_keyword', - 'is_active' => false - ]); - - $keywords = $route->keywords; - $activeKeywords = $keywords->where('is_active', true); - $inactiveKeywords = $keywords->where('is_active', false); - - $this->assertCount(2, $keywords); - $this->assertCount(1, $activeKeywords); - $this->assertCount(1, $inactiveKeywords); - $this->assertEquals('active_keyword', $activeKeywords->first()->keyword); - $this->assertEquals('inactive_keyword', $inactiveKeywords->first()->keyword); - } -} \ No newline at end of file diff --git a/tests/Unit/Modules/Lemmy/LemmyRequestTest.php b/tests/Unit/Modules/Lemmy/LemmyRequestTest.php deleted file mode 100644 index 18cc955..0000000 --- a/tests/Unit/Modules/Lemmy/LemmyRequestTest.php +++ /dev/null @@ -1,273 +0,0 @@ -assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - $this->assertNull($this->getPrivateProperty($request, 'token')); - } - - public function test_constructor_with_https_url(): void - { - $request = new LemmyRequest('https://lemmy.world'); - - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - } - - public function test_constructor_with_http_url(): void - { - $request = new LemmyRequest('http://lemmy.world'); - - $this->assertEquals('http', $this->getPrivateProperty($request, 'scheme')); - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - } - - public function test_constructor_with_trailing_slash(): void - { - $request = new LemmyRequest('lemmy.world/'); - - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - } - - public function test_constructor_with_full_url_and_trailing_slash(): void - { - $request = new LemmyRequest('https://lemmy.world/'); - - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - } - - public function test_constructor_with_token(): void - { - $request = new LemmyRequest('lemmy.world', 'test-token'); - - $this->assertEquals('test-token', $this->getPrivateProperty($request, 'token')); - } - - public function test_constructor_preserves_case_in_scheme_detection(): void - { - $request = new LemmyRequest('HTTPS://lemmy.world'); - - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_scheme_sets_https(): void - { - $request = new LemmyRequest('lemmy.world'); - $result = $request->withScheme('https'); - - $this->assertSame($request, $result); - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_scheme_sets_http(): void - { - $request = new LemmyRequest('lemmy.world'); - $result = $request->withScheme('http'); - - $this->assertSame($request, $result); - $this->assertEquals('http', $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_scheme_normalizes_case(): void - { - $request = new LemmyRequest('lemmy.world'); - $request->withScheme('HTTPS'); - - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_scheme_ignores_invalid_schemes(): void - { - $request = new LemmyRequest('lemmy.world'); - $originalScheme = $this->getPrivateProperty($request, 'scheme'); - - $request->withScheme('ftp'); - - $this->assertEquals($originalScheme, $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_token_sets_token(): void - { - $request = new LemmyRequest('lemmy.world'); - $result = $request->withToken('new-token'); - - $this->assertSame($request, $result); - $this->assertEquals('new-token', $this->getPrivateProperty($request, 'token')); - } - - public function test_get_without_token(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $response = $request->get('site'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/site' - && !$httpRequest->hasHeader('Authorization'); - }); - } - - public function test_get_with_token(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world', 'test-token'); - $response = $request->get('site'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/site' - && $httpRequest->header('Authorization')[0] === 'Bearer test-token'; - }); - } - - public function test_get_with_parameters(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $params = ['limit' => 10, 'page' => 1]; - $response = $request->get('posts', $params); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) use ($params) { - $url = $httpRequest->url(); - return str_contains($url, 'https://lemmy.world/api/v3/posts') - && str_contains($url, 'limit=10') - && str_contains($url, 'page=1'); - }); - } - - public function test_get_with_http_scheme(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $request->withScheme('http'); - $response = $request->get('site'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'http://lemmy.world/api/v3/site'; - }); - } - - public function test_post_without_token(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $response = $request->post('login'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/login' - && $httpRequest->method() === 'POST' - && !$httpRequest->hasHeader('Authorization'); - }); - } - - public function test_post_with_token(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world', 'test-token'); - $response = $request->post('login'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/login' - && $httpRequest->method() === 'POST' - && $httpRequest->header('Authorization')[0] === 'Bearer test-token'; - }); - } - - public function test_post_with_data(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $data = ['username' => 'test', 'password' => 'pass']; - $response = $request->post('login', $data); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) use ($data) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/login' - && $httpRequest->method() === 'POST' - && $httpRequest->data() === $data; - }); - } - - public function test_post_with_http_scheme(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $request->withScheme('http'); - $response = $request->post('login'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'http://lemmy.world/api/v3/login'; - }); - } - - public function test_requests_use_30_second_timeout(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $request->get('site'); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/site'; - }); - } - - public function test_chaining_methods(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $response = $request->withScheme('http')->withToken('chained-token')->get('site'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'http://lemmy.world/api/v3/site' - && $httpRequest->header('Authorization')[0] === 'Bearer chained-token'; - }); - } - - private function getPrivateProperty(object $object, string $property): mixed - { - $reflection = new \ReflectionClass($object); - $reflectionProperty = $reflection->getProperty($property); - $reflectionProperty->setAccessible(true); - - return $reflectionProperty->getValue($object); - } -} \ No newline at end of file diff --git a/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php b/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php deleted file mode 100644 index 784b633..0000000 --- a/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php +++ /dev/null @@ -1,429 +0,0 @@ -getProperty('instance'); - $property->setAccessible(true); - - $this->assertEquals('lemmy.world', $property->getValue($service)); - } - - public function test_login_with_https_success(): void - { - Http::fake([ - 'https://lemmy.world/api/v3/user/login' => Http::response(['jwt' => 'test-token'], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'pass'); - - $this->assertEquals('test-token', $token); - - Http::assertSent(function ($request) { - return $request->url() === 'https://lemmy.world/api/v3/user/login' - && $request['username_or_email'] === 'user' - && $request['password'] === 'pass'; - }); - } - - public function test_login_falls_back_to_http_on_https_failure(): void - { - Http::fake([ - 'https://lemmy.world/api/v3/user/login' => Http::response('', 500), - 'http://lemmy.world/api/v3/user/login' => Http::response(['jwt' => 'http-token'], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'pass'); - - $this->assertEquals('http-token', $token); - - Http::assertSentCount(2); - } - - public function test_login_with_explicit_http_scheme(): void - { - Http::fake([ - 'http://localhost/api/v3/user/login' => Http::response(['jwt' => 'local-token'], 200) - ]); - - $service = new LemmyApiService('http://localhost'); - $token = $service->login('user', 'pass'); - - $this->assertEquals('local-token', $token); - - Http::assertSent(function ($request) { - return $request->url() === 'http://localhost/api/v3/user/login'; - }); - } - - public function test_login_with_explicit_https_scheme(): void - { - Http::fake([ - 'https://secure.lemmy/api/v3/user/login' => Http::response(['jwt' => 'secure-token'], 200) - ]); - - $service = new LemmyApiService('https://secure.lemmy'); - $token = $service->login('user', 'pass'); - - $this->assertEquals('secure-token', $token); - - Http::assertSent(function ($request) { - return $request->url() === 'https://secure.lemmy/api/v3/user/login'; - }); - } - - public function test_login_returns_null_on_unsuccessful_response(): void - { - Http::fake([ - '*' => Http::response(['error' => 'Invalid credentials'], 401) - ]); - - Log::shouldReceive('error')->twice(); // Once for HTTPS, once for HTTP fallback - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'wrong'); - - $this->assertNull($token); - } - - public function test_login_handles_rate_limit_error(): void - { - Http::fake([ - '*' => Http::response('{"error":"rate_limit_error"}', 429) - ]); - - // Expecting 4 error logs: - // 1. 'Lemmy login failed' for HTTPS attempt - // 2. 'Lemmy login exception' for catching the rate limit exception on HTTPS - // 3. 'Lemmy login failed' for HTTP attempt - // 4. 'Lemmy login exception' for catching the rate limit exception on HTTP - Log::shouldReceive('error')->times(4); - - $service = new LemmyApiService('lemmy.world'); - $result = $service->login('user', 'pass'); - - // Since the exception is caught and HTTP is tried, then that also fails, - // the method returns null instead of throwing - $this->assertNull($result); - } - - public function test_login_returns_null_when_jwt_missing_from_response(): void - { - Http::fake([ - '*' => Http::response(['success' => true], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'pass'); - - $this->assertNull($token); - } - - public function test_login_handles_exception_and_returns_null(): void - { - Http::fake(function () { - throw new Exception('Network error'); - }); - - Log::shouldReceive('error')->twice(); - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'pass'); - - $this->assertNull($token); - } - - public function test_get_community_id_success(): void - { - Http::fake([ - '*' => Http::response([ - 'community_view' => [ - 'community' => ['id' => 123] - ] - ], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $id = $service->getCommunityId('test-community', 'token'); - - $this->assertEquals(123, $id); - - Http::assertSent(function ($request) { - return str_contains($request->url(), '/api/v3/community') - && str_contains($request->url(), 'name=test-community') - && $request->header('Authorization')[0] === 'Bearer token'; - }); - } - - public function test_get_community_id_throws_on_unsuccessful_response(): void - { - Http::fake([ - '*' => Http::response('Not found', 404) - ]); - - Log::shouldReceive('error')->once(); - - $service = new LemmyApiService('lemmy.world'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Failed to fetch community: 404'); - - $service->getCommunityId('missing', 'token'); - } - - public function test_get_community_id_throws_when_community_not_in_response(): void - { - Http::fake([ - '*' => Http::response(['success' => true], 200) - ]); - - Log::shouldReceive('error')->once(); - - $service = new LemmyApiService('lemmy.world'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Community not found'); - - $service->getCommunityId('test', 'token'); - } - - public function test_sync_channel_posts_success(): void - { - Http::fake([ - '*' => Http::response([ - 'posts' => [ - [ - 'post' => [ - 'id' => 1, - 'url' => 'https://example.com/1', - 'name' => 'Post 1', - 'published' => '2024-01-01T00:00:00Z' - ] - ], - [ - 'post' => [ - 'id' => 2, - 'url' => 'https://example.com/2', - 'name' => 'Post 2', - 'published' => '2024-01-02T00:00:00Z' - ] - ] - ] - ], 200) - ]); - - Log::shouldReceive('info')->once()->with('Synced channel posts', Mockery::any()); - - $mockPost = Mockery::mock('alias:' . PlatformChannelPost::class); - $mockPost->shouldReceive('storePost') - ->twice() - ->with( - PlatformEnum::LEMMY, - Mockery::any(), - 'test-community', - Mockery::any(), - Mockery::any(), - Mockery::any(), - Mockery::any() - ); - - $service = new LemmyApiService('lemmy.world'); - $service->syncChannelPosts('token', 42, 'test-community'); - - Http::assertSent(function ($request) { - return str_contains($request->url(), '/api/v3/post/list') - && str_contains($request->url(), 'community_id=42') - && str_contains($request->url(), 'limit=50') - && str_contains($request->url(), 'sort=New'); - }); - } - - public function test_sync_channel_posts_handles_unsuccessful_response(): void - { - Http::fake([ - '*' => Http::response('Error', 500) - ]); - - Log::shouldReceive('warning')->once()->with('Failed to sync channel posts', Mockery::any()); - - $service = new LemmyApiService('lemmy.world'); - $service->syncChannelPosts('token', 42, 'test-community'); - - Http::assertSentCount(1); - } - - public function test_sync_channel_posts_handles_exception(): void - { - Http::fake(function () { - throw new Exception('Network error'); - }); - - Log::shouldReceive('error')->once()->with('Exception while syncing channel posts', Mockery::any()); - - $service = new LemmyApiService('lemmy.world'); - $service->syncChannelPosts('token', 42, 'test-community'); - - // Assert that the method completes without throwing - $this->assertTrue(true); - } - - public function test_create_post_with_all_parameters(): void - { - Http::fake([ - '*' => Http::response(['post_view' => ['post' => ['id' => 999]]], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $result = $service->createPost( - 'token', - 'Test Title', - 'Test Body', - 42, - 'https://example.com', - 'https://example.com/thumb.jpg', - 5 - ); - - $this->assertEquals(['post_view' => ['post' => ['id' => 999]]], $result); - - Http::assertSent(function ($request) { - $data = $request->data(); - return $request->url() === 'https://lemmy.world/api/v3/post' - && $data['name'] === 'Test Title' - && $data['body'] === 'Test Body' - && $data['community_id'] === 42 - && $data['url'] === 'https://example.com' - && $data['custom_thumbnail'] === 'https://example.com/thumb.jpg' - && $data['language_id'] === 5; - }); - } - - public function test_create_post_with_minimal_parameters(): void - { - Http::fake([ - '*' => Http::response(['post_view' => ['post' => ['id' => 888]]], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $result = $service->createPost( - 'token', - 'Title Only', - 'Body Only', - 42 - ); - - $this->assertEquals(['post_view' => ['post' => ['id' => 888]]], $result); - - Http::assertSent(function ($request) { - $data = $request->data(); - return $request->url() === 'https://lemmy.world/api/v3/post' - && $data['name'] === 'Title Only' - && $data['body'] === 'Body Only' - && $data['community_id'] === 42 - && !isset($data['url']) - && !isset($data['custom_thumbnail']) - && !isset($data['language_id']); - }); - } - - public function test_create_post_throws_on_unsuccessful_response(): void - { - Http::fake([ - '*' => Http::response('Forbidden', 403) - ]); - - Log::shouldReceive('error')->once(); - - $service = new LemmyApiService('lemmy.world'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Failed to create post: 403'); - - $service->createPost('token', 'Title', 'Body', 42); - } - - public function test_get_languages_success(): void - { - Http::fake([ - '*' => Http::response([ - 'all_languages' => [ - ['id' => 1, 'code' => 'en', 'name' => 'English'], - ['id' => 2, 'code' => 'fr', 'name' => 'French'] - ] - ], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $languages = $service->getLanguages(); - - $this->assertCount(2, $languages); - $this->assertEquals('en', $languages[0]['code']); - $this->assertEquals('fr', $languages[1]['code']); - - Http::assertSent(function ($request) { - return str_contains($request->url(), '/api/v3/site'); - }); - } - - public function test_get_languages_returns_empty_array_on_failure(): void - { - Http::fake([ - '*' => Http::response('Error', 500) - ]); - - Log::shouldReceive('warning')->once(); - - $service = new LemmyApiService('lemmy.world'); - $languages = $service->getLanguages(); - - $this->assertEquals([], $languages); - } - - public function test_get_languages_handles_exception(): void - { - Http::fake(function () { - throw new Exception('Network error'); - }); - - Log::shouldReceive('error')->once(); - - $service = new LemmyApiService('lemmy.world'); - $languages = $service->getLanguages(); - - $this->assertEquals([], $languages); - } - - public function test_get_languages_returns_empty_when_all_languages_missing(): void - { - Http::fake([ - '*' => Http::response(['site_view' => []], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $languages = $service->getLanguages(); - - $this->assertEquals([], $languages); - } -} diff --git a/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php b/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php deleted file mode 100644 index 5f17c6b..0000000 --- a/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php +++ /dev/null @@ -1,342 +0,0 @@ -make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $this->assertInstanceOf(LemmyApiService::class, $apiProperty->getValue($publisher)); - - $accountProperty = $reflection->getProperty('account'); - $accountProperty->setAccessible(true); - $this->assertSame($account, $accountProperty->getValue($publisher)); - } - - public function test_publish_to_channel_with_all_data(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => '42' - ]); - - $extractedData = [ - 'title' => 'Test Article', - 'description' => 'Test Description', - 'thumbnail' => 'https://example.com/thumb.jpg', - 'language_id' => 5 - ]; - - // Mock LemmyAuthService via service container - $authMock = Mockery::mock(LemmyAuthService::class); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andReturn('test-token'); - - $this->app->instance(LemmyAuthService::class, $authMock); - - // Mock LemmyApiService - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('createPost') - ->once() - ->with( - 'test-token', - 'Test Article', - 'Test Description', - 42, - 'https://example.com/article', - 'https://example.com/thumb.jpg', - 5 - ) - ->andReturn(['post_view' => ['post' => ['id' => 999]]]); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $result = $publisher->publishToChannel($article, $extractedData, $channel); - - $this->assertEquals(['post_view' => ['post' => ['id' => 999]]], $result); - } - - public function test_publish_to_channel_with_minimal_data(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => '24' - ]); - - $extractedData = []; - - // Mock LemmyAuthService - $authMock = Mockery::mock(LemmyAuthService::class); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andReturn('minimal-token'); - - $this->app->instance(LemmyAuthService::class, $authMock); - - // Mock LemmyApiService - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('createPost') - ->once() - ->with( - 'minimal-token', - 'Untitled', - '', - 24, - 'https://example.com/article', - null, - null - ) - ->andReturn(['post_view' => ['post' => ['id' => 777]]]); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $result = $publisher->publishToChannel($article, $extractedData, $channel); - - $this->assertEquals(['post_view' => ['post' => ['id' => 777]]], $result); - } - - public function test_publish_to_channel_without_thumbnail(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => '33' - ]); - - $extractedData = [ - 'title' => 'No Thumbnail Article', - 'description' => 'Article without thumbnail', - 'language_id' => 2 - ]; - - // Mock LemmyAuthService - $authMock = Mockery::mock(LemmyAuthService::class); - $this->app->instance(LemmyAuthService::class, $authMock); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andReturn('no-thumb-token'); - - // Mock LemmyApiService - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('createPost') - ->once() - ->with( - 'no-thumb-token', - 'No Thumbnail Article', - 'Article without thumbnail', - 33, - 'https://example.com/article', - null, - 2 - ) - ->andReturn(['post_view' => ['post' => ['id' => 555]]]); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $result = $publisher->publishToChannel($article, $extractedData, $channel); - - $this->assertEquals(['post_view' => ['post' => ['id' => 555]]], $result); - } - - public function test_publish_to_channel_throws_platform_auth_exception(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make(); - $channel = PlatformChannel::factory()->make(); - $extractedData = []; - - // Mock LemmyAuthService to throw exception - $authMock = Mockery::mock(LemmyAuthService::class); - $this->app->instance(LemmyAuthService::class, $authMock); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andThrow(new PlatformAuthException(PlatformEnum::LEMMY, 'Auth failed')); - - $publisher = new LemmyPublisher($account); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Auth failed'); - - $publisher->publishToChannel($article, $extractedData, $channel); - } - - public function test_publish_to_channel_throws_api_exception(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => '42' - ]); - - $extractedData = [ - 'title' => 'Test Article' - ]; - - // Mock LemmyAuthService via service container - $authMock = Mockery::mock(LemmyAuthService::class); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andReturn('test-token'); - - $this->app->instance(LemmyAuthService::class, $authMock); - - // Mock LemmyApiService to throw exception - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('createPost') - ->once() - ->andThrow(new Exception('API Error')); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('API Error'); - - $publisher->publishToChannel($article, $extractedData, $channel); - } - - public function test_publish_to_channel_handles_string_channel_id(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => 'string-42' - ]); - - $extractedData = [ - 'title' => 'Test Title' - ]; - - // Mock LemmyAuthService - $authMock = Mockery::mock(LemmyAuthService::class); - $this->app->instance(LemmyAuthService::class, $authMock); - $authMock->shouldReceive('getToken') - ->once() - ->andReturn('token'); - - // Mock LemmyApiService - should call getCommunityId for non-numeric channel_id - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('getCommunityId') - ->once() - ->with('string-42', 'token') - ->andReturn(42); - $apiMock->shouldReceive('createPost') - ->once() - ->with( - 'token', - 'Test Title', - '', - 42, // resolved community ID - 'https://example.com/article', - null, - null - ) - ->andReturn(['success' => true]); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $result = $publisher->publishToChannel($article, $extractedData, $channel); - - $this->assertEquals(['success' => true], $result); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/Auth/LemmyAuthServiceTest.php b/tests/Unit/Services/Auth/LemmyAuthServiceTest.php deleted file mode 100644 index d77f46b..0000000 --- a/tests/Unit/Services/Auth/LemmyAuthServiceTest.php +++ /dev/null @@ -1,162 +0,0 @@ - Http::response(['jwt' => 'jwt-123'], 200), - 'http://lemmy.test/api/v3/user/login' => Http::response(['jwt' => 'jwt-123'], 200) - ]); - - $account = PlatformAccount::factory()->create([ - 'username' => 'testuser', - 'password' => 'testpass', - 'instance_url' => 'https://lemmy.test' - ]); - - $result = app(LemmyAuthService::class)->getToken($account); - - $this->assertEquals('jwt-123', $result); - } - - public function test_get_token_throws_exception_when_username_missing(): void - { - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => null, - 'password' => 'testpass', - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Missing credentials for account: '); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_get_token_throws_exception_when_password_missing(): void - { - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => 'testuser', - 'password' => null, - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Missing credentials for account: testuser'); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_get_token_throws_exception_when_instance_url_missing(): void - { - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => 'testuser', - 'password' => 'testpass', - 'instance_url' => null, - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Missing credentials for account: testuser'); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_get_token_throws_exception_when_login_fails(): void - { - // Mock failed HTTP response for both HTTPS and HTTP - Http::fake([ - 'https://lemmy.test/api/v3/user/login' => Http::response(['error' => 'Invalid credentials'], 401), - 'http://lemmy.test/api/v3/user/login' => Http::response(['error' => 'Invalid credentials'], 401) - ]); - - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => 'failingUser', - 'password' => 'badpass', - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Login failed for account: failingUser'); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_get_token_throws_exception_when_login_returns_false(): void - { - // Mock response with empty/missing JWT for both HTTPS and HTTP - Http::fake([ - 'https://lemmy.test/api/v3/user/login' => Http::response(['success' => false], 200), - 'http://lemmy.test/api/v3/user/login' => Http::response(['success' => false], 200) - ]); - - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => 'emptyUser', - 'password' => 'pass', - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Login failed for account: emptyUser'); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_platform_auth_exception_contains_correct_platform(): void - { - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => null, - 'password' => 'testpass', - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - try { - app(LemmyAuthService::class)->getToken($account); - $this->fail('Expected PlatformAuthException to be thrown'); - } catch (PlatformAuthException $e) { - $this->assertEquals(PlatformEnum::LEMMY, $e->getPlatform()); - } - } -} \ No newline at end of file diff --git a/tests/Unit/Services/DashboardStatsServiceTest.php b/tests/Unit/Services/DashboardStatsServiceTest.php deleted file mode 100644 index d8cfc4b..0000000 --- a/tests/Unit/Services/DashboardStatsServiceTest.php +++ /dev/null @@ -1,42 +0,0 @@ - Http::response('', 500) - ]); - } - - public function test_get_available_periods_returns_correct_options(): void - { - $service = new DashboardStatsService(); - $periods = $service->getAvailablePeriods(); - - $this->assertIsArray($periods); - $this->assertArrayHasKey('today', $periods); - $this->assertArrayHasKey('week', $periods); - $this->assertArrayHasKey('month', $periods); - $this->assertArrayHasKey('year', $periods); - $this->assertArrayHasKey('all', $periods); - - $this->assertEquals('Today', $periods['today']); - $this->assertEquals('All Time', $periods['all']); - } - - public function test_service_instantiation(): void - { - $service = new DashboardStatsService(); - $this->assertInstanceOf(DashboardStatsService::class, $service); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php b/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php deleted file mode 100644 index cb99b5e..0000000 --- a/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php +++ /dev/null @@ -1,334 +0,0 @@ -'; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertEquals('Test Article Title', $title); - } - - public function test_extract_title_from_h1_tag(): void - { - $html = '

H1 Title Test

'; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertEquals('H1 Title Test', $title); - } - - public function test_extract_title_from_title_tag(): void - { - $html = 'Page Title Test'; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertEquals('Page Title Test', $title); - } - - public function test_extract_title_with_html_entities(): void - { - $html = ''; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertEquals('Test & Article "Title"', $title); - } - - public function test_extract_title_returns_null_when_not_found(): void - { - $html = '

No title here

'; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertNull($title); - } - - public function test_extract_description_from_og_meta_tag(): void - { - $html = ''; - - $description = BelgaArticlePageParser::extractDescription($html); - - $this->assertEquals('Test article description', $description); - } - - public function test_extract_description_from_paragraph(): void - { - $html = '

This is the first paragraph description.

'; - - $description = BelgaArticlePageParser::extractDescription($html); - - $this->assertEquals('This is the first paragraph description.', $description); - } - - public function test_extract_description_with_html_entities(): void - { - $html = ''; - - $description = BelgaArticlePageParser::extractDescription($html); - - $this->assertEquals('Description with & entities ', $description); - } - - public function test_extract_description_returns_null_when_not_found(): void - { - $html = '
No description here
'; - - $description = BelgaArticlePageParser::extractDescription($html); - - $this->assertNull($description); - } - - public function test_extract_full_article_from_belga_paragraph_class(): void - { - $html = ' - - -

First paragraph content.

-

Second paragraph content.

-

This should be ignored.

- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "First paragraph content.\n\nSecond paragraph content."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_filters_empty_paragraphs(): void - { - $html = ' - - -

Content paragraph.

-

-

-

Another content paragraph.

- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "Content paragraph.\n\nAnother content paragraph."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_handles_nested_tags(): void - { - $html = ' - - -

This has bold text and italic text.

-

This has a link inside.

- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "This has bold text and italic text.\n\nThis has a link inside."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_removes_scripts_and_styles(): void - { - $html = ' - - - - - - -

Clean content.

- - - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $this->assertEquals('Clean content.', $fullArticle); - $this->assertStringNotContainsString('console.log', $fullArticle); - $this->assertStringNotContainsString('alert', $fullArticle); - $this->assertStringNotContainsString('color: red', $fullArticle); - } - - public function test_extract_full_article_fallback_to_prezly_document(): void - { - $html = ' - - -
-

Content from prezly section.

-

More prezly content.

-
- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "Content from prezly section.\n\nMore prezly content."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_fallback_to_all_paragraphs(): void - { - $html = ' - - -

First general paragraph.

-

Second general paragraph.

- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "First general paragraph.\n\nSecond general paragraph."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_returns_null_when_no_content(): void - { - $html = '
No paragraphs here
'; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $this->assertNull($fullArticle); - } - - public function test_extract_thumbnail_from_og_image(): void - { - $html = ''; - - $thumbnail = BelgaArticlePageParser::extractThumbnail($html); - - $this->assertEquals('https://example.com/image.jpg', $thumbnail); - } - - public function test_extract_thumbnail_from_img_tag(): void - { - $html = 'test'; - - $thumbnail = BelgaArticlePageParser::extractThumbnail($html); - - $this->assertEquals('https://example.com/article-image.png', $thumbnail); - } - - public function test_extract_thumbnail_prefers_og_image(): void - { - $html = ' - - - test - - '; - - $thumbnail = BelgaArticlePageParser::extractThumbnail($html); - - $this->assertEquals('https://example.com/og-image.jpg', $thumbnail); - } - - public function test_extract_thumbnail_returns_null_when_not_found(): void - { - $html = '
No images here
'; - - $thumbnail = BelgaArticlePageParser::extractThumbnail($html); - - $this->assertNull($thumbnail); - } - - public function test_extract_data_returns_all_components(): void - { - $html = ' - - - - - - - -

Full article content here.

- - - '; - - $data = BelgaArticlePageParser::extractData($html); - - $this->assertIsArray($data); - $this->assertArrayHasKey('title', $data); - $this->assertArrayHasKey('description', $data); - $this->assertArrayHasKey('full_article', $data); - $this->assertArrayHasKey('thumbnail', $data); - - $this->assertEquals('Test Article', $data['title']); - $this->assertEquals('Test description', $data['description']); - $this->assertEquals('Full article content here.', $data['full_article']); - $this->assertEquals('https://example.com/image.jpg', $data['thumbnail']); - } - - public function test_extract_data_handles_missing_components_gracefully(): void - { - $html = '
Minimal content
'; - - $data = BelgaArticlePageParser::extractData($html); - - $this->assertIsArray($data); - $this->assertArrayHasKey('title', $data); - $this->assertArrayHasKey('description', $data); - $this->assertArrayHasKey('full_article', $data); - $this->assertArrayHasKey('thumbnail', $data); - - $this->assertNull($data['title']); - $this->assertNull($data['description']); - $this->assertNull($data['full_article']); - $this->assertNull($data['thumbnail']); - } - - /** - * Test based on actual Belga HTML structure from real article - */ - public function test_extract_full_article_with_realistic_belga_html(): void - { - $html = ' - - -
-
-

Around 110,000 people joined the Antwerp Pride Parade on Saturday afternoon, according to police.

-

The event passed without major incidents. Earlier in the day, far-right group Voorpost held a pre-approved protest.

-

Police say they expect no problems with crowd dispersal, as departures will be staggered.

-
-
- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $this->assertNotNull($fullArticle); - $this->assertStringContainsString('110,000 people joined', $fullArticle); - $this->assertStringContainsString('major incidents', $fullArticle); - $this->assertStringContainsString('crowd dispersal', $fullArticle); - - // Should join paragraphs with double newlines - $this->assertStringContainsString("\n\n", $fullArticle); - - // Should strip HTML tags - $this->assertStringNotContainsString('', $fullArticle); - $this->assertStringNotContainsString('', $fullArticle); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php deleted file mode 100644 index ed58673..0000000 --- a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ /dev/null @@ -1,299 +0,0 @@ -logSaver = Mockery::mock(LogSaver::class); - $this->logSaver->shouldReceive('info')->zeroOrMoreTimes(); - $this->logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $this->logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $this->logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - $this->service = new ArticlePublishingService($this->logSaver); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_publish_to_routed_channels_throws_exception_for_invalid_article(): void - { - $article = Article::factory()->create(['approval_status' => 'rejected']); - $extractedData = ['title' => 'Test Title']; - - $this->expectException(PublishException::class); - $this->expectExceptionMessage('CANNOT_PUBLISH_INVALID_ARTICLE'); - - $this->service->publishToRoutedChannels($article, $extractedData); - } - - public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved' - ]); - $extractedData = ['title' => 'Test Title']; - - $result = $this->service->publishToRoutedChannels($article, $extractedData); - - $this->assertInstanceOf(EloquentCollection::class, $result); - $this->assertTrue($result->isEmpty()); - } - - public function test_publish_to_routed_channels_skips_routes_without_active_accounts(): void - { - // Arrange: valid article - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); - - // Create a route with a channel but no active accounts - $channel = PlatformChannel::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Don't create any platform accounts for the channel - - // Act - $result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']); - - // Assert - $this->assertTrue($result->isEmpty()); - $this->assertDatabaseCount('article_publications', 0); - } - - public function test_publish_to_routed_channels_successfully_publishes_to_channel(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); - - $platformInstance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - // Create route - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Attach account to channel as active - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - - // Mock publisher via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once() - ->andReturn(['post_view' => ['post' => ['id' => 123]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(1, $result); - $this->assertDatabaseHas('article_publications', [ - 'article_id' => $article->id, - 'platform_channel_id' => $channel->id, - 'post_id' => 123, - 'published_by' => $account->username, - ]); - } - - public function test_publish_to_routed_channels_handles_publishing_failure_gracefully(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); - - $platformInstance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - // Create route - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Attach account to channel as active - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - - // Publisher throws an exception via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once() - ->andThrow(new Exception('network error')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertTrue($result->isEmpty()); - $this->assertDatabaseCount('article_publications', 0); - } - - public function test_publish_to_routed_channels_publishes_to_multiple_routes(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 100]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 200]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(2, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 100]); - $this->assertDatabaseHas('article_publications', ['post_id' => 200]); - } - - public function test_publish_to_routed_channels_filters_out_failed_publications(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 300]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andThrow(new Exception('failed')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(1, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 300]); - $this->assertDatabaseCount('article_publications', 1); - } -} diff --git a/tests/Unit/Services/Publishing/KeywordFilteringTest.php b/tests/Unit/Services/Publishing/KeywordFilteringTest.php deleted file mode 100644 index 0b98504..0000000 --- a/tests/Unit/Services/Publishing/KeywordFilteringTest.php +++ /dev/null @@ -1,276 +0,0 @@ -shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - $this->service = new ArticlePublishingService($logSaver); - $this->feed = Feed::factory()->create(); - $this->channel1 = PlatformChannel::factory()->create(); - $this->channel2 = PlatformChannel::factory()->create(); - - // Create routes - $this->route1 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - $this->route2 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'is_active' => true, - 'priority' => 50 - ]); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_route_with_no_keywords_matches_all_articles(): void - { - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'Some random article', - 'description' => 'This is about something', - 'full_article' => 'The content talks about various topics' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route with no keywords should match any article'); - } - - public function test_route_with_keywords_matches_article_containing_keyword(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => true - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route should match article containing keyword "Belgium"'); - } - - public function test_route_with_keywords_does_not_match_article_without_keywords(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'sports', - 'is_active' => true - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'football', - 'is_active' => true - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'Economic news update', - 'description' => 'Markets are doing well', - 'full_article' => 'The economy is showing strong growth this quarter...' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertFalse($result, 'Route should not match article without any keywords'); - } - - public function test_inactive_keywords_are_ignored(): void - { - // Add active and inactive keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => false // Inactive - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true // Active - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedDataWithInactiveKeyword = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...' - ]; - - $extractedDataWithActiveKeyword = [ - 'title' => 'Political changes ahead', - 'description' => 'Politics is changing', - 'full_article' => 'The political landscape is shifting...' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithInactiveKeyword]); - $result2 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithActiveKeyword]); - - $this->assertFalse($result1, 'Route should not match article with inactive keyword'); - $this->assertTrue($result2, 'Route should match article with active keyword'); - } - - public function test_keyword_matching_is_case_insensitive(): void - { - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'BELGIUM', - 'is_active' => true - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'belgium news', - 'description' => 'About Belgium', - 'full_article' => 'News from belgium today...' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Keyword matching should be case insensitive'); - } - - public function test_keywords_match_in_title_description_and_content(): void - { - $keywordInTitle = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'title-word', - 'is_active' => true - ]); - - $keywordInDescription = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'keyword' => 'desc-word', - 'is_active' => true - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'This contains title-word', - 'description' => 'This has desc-word in it', - 'full_article' => 'The content has no special words' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - $result2 = $method->invokeArgs($this->service, [$this->route2, $extractedData]); - - $this->assertTrue($result1, 'Should match keyword in title'); - $this->assertTrue($result2, 'Should match keyword in description'); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/SystemStatusServiceTest.php b/tests/Unit/Services/SystemStatusServiceTest.php deleted file mode 100644 index cd1c863..0000000 --- a/tests/Unit/Services/SystemStatusServiceTest.php +++ /dev/null @@ -1,44 +0,0 @@ - Http::response('', 500) - ]); - } - - public function test_service_instantiation(): void - { - $service = new SystemStatusService(); - $this->assertInstanceOf(SystemStatusService::class, $service); - } - - public function test_get_system_status_returns_correct_structure(): void - { - $service = new SystemStatusService(); - $status = $service->getSystemStatus(); - - $this->assertIsArray($status); - $this->assertArrayHasKey('is_enabled', $status); - $this->assertArrayHasKey('status', $status); - $this->assertArrayHasKey('status_class', $status); - $this->assertArrayHasKey('reasons', $status); - - // Without database setup, system should be disabled - $this->assertFalse($status['is_enabled']); - $this->assertEquals('Disabled', $status['status']); - $this->assertEquals('text-red-600', $status['status_class']); - $this->assertIsArray($status['reasons']); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/ValidationServiceKeywordTest.php b/tests/Unit/Services/ValidationServiceKeywordTest.php deleted file mode 100644 index beaf87c..0000000 --- a/tests/Unit/Services/ValidationServiceKeywordTest.php +++ /dev/null @@ -1,210 +0,0 @@ -createArticleFetcher(); - $this->validationService = new ValidationService($articleFetcher); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * Helper method to access private validateByKeywords method - */ - private function getValidateByKeywordsMethod(): ReflectionMethod - { - $reflection = new ReflectionClass($this->validationService); - $method = $reflection->getMethod('validateByKeywords'); - $method->setAccessible(true); - return $method; - } - - public function test_validates_belgian_political_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This article discusses N-VA party policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Bart De Wever made a statement today.')); - $this->assertTrue($method->invoke($this->validationService, 'Frank Vandenbroucke announced new healthcare policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Alexander De Croo addressed the nation.')); - $this->assertTrue($method->invoke($this->validationService, 'The Vooruit party proposed new legislation.')); - $this->assertTrue($method->invoke($this->validationService, 'Open Vld supports the new budget.')); - $this->assertTrue($method->invoke($this->validationService, 'CD&V members voted on the proposal.')); - $this->assertTrue($method->invoke($this->validationService, 'Vlaams Belang criticized the decision.')); - $this->assertTrue($method->invoke($this->validationService, 'PTB organized a protest yesterday.')); - $this->assertTrue($method->invoke($this->validationService, 'PVDA released a statement.')); - } - - public function test_validates_belgian_location_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This event took place in Belgium.')); - $this->assertTrue($method->invoke($this->validationService, 'The Belgian government announced new policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Flanders saw increased tourism this year.')); - $this->assertTrue($method->invoke($this->validationService, 'The Flemish government supports this initiative.')); - $this->assertTrue($method->invoke($this->validationService, 'Wallonia will receive additional funding.')); - $this->assertTrue($method->invoke($this->validationService, 'Brussels hosted the international conference.')); - $this->assertTrue($method->invoke($this->validationService, 'Antwerp Pride attracted thousands of participants.')); - $this->assertTrue($method->invoke($this->validationService, 'Ghent University published the research.')); - $this->assertTrue($method->invoke($this->validationService, 'Bruges tourism numbers increased.')); - $this->assertTrue($method->invoke($this->validationService, 'Leuven students organized the protest.')); - $this->assertTrue($method->invoke($this->validationService, 'Mechelen city council voted on the proposal.')); - $this->assertTrue($method->invoke($this->validationService, 'Namur hosted the cultural event.')); - $this->assertTrue($method->invoke($this->validationService, 'Liège airport saw increased traffic.')); - $this->assertTrue($method->invoke($this->validationService, 'Charleroi industrial zone expanded.')); - } - - public function test_validates_government_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'Parliament voted on the new legislation.')); - $this->assertTrue($method->invoke($this->validationService, 'The government announced budget cuts.')); - $this->assertTrue($method->invoke($this->validationService, 'The minister addressed concerns about healthcare.')); - $this->assertTrue($method->invoke($this->validationService, 'New policy changes will take effect next month.')); - $this->assertTrue($method->invoke($this->validationService, 'The law was passed with majority support.')); - $this->assertTrue($method->invoke($this->validationService, 'New legislation affects education funding.')); - } - - public function test_validates_news_topic_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'The economy showed signs of recovery.')); - $this->assertTrue($method->invoke($this->validationService, 'Economic indicators improved this quarter.')); - $this->assertTrue($method->invoke($this->validationService, 'Education reforms were announced today.')); - $this->assertTrue($method->invoke($this->validationService, 'Healthcare workers received additional support.')); - $this->assertTrue($method->invoke($this->validationService, 'Transport infrastructure will be upgraded.')); - $this->assertTrue($method->invoke($this->validationService, 'Climate change policies were discussed.')); - $this->assertTrue($method->invoke($this->validationService, 'Energy prices have increased significantly.')); - $this->assertTrue($method->invoke($this->validationService, 'European Union voted on trade agreements.')); - $this->assertTrue($method->invoke($this->validationService, 'EU sanctions were extended.')); - $this->assertTrue($method->invoke($this->validationService, 'Migration policies need urgent review.')); - $this->assertTrue($method->invoke($this->validationService, 'Security measures were enhanced.')); - $this->assertTrue($method->invoke($this->validationService, 'Justice system reforms are underway.')); - $this->assertTrue($method->invoke($this->validationService, 'Culture festivals received government funding.')); - $this->assertTrue($method->invoke($this->validationService, 'Police reported 18 administrative detentions.')); - } - - public function test_case_insensitive_keyword_matching(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This article mentions ANTWERP in capital letters.')); - $this->assertTrue($method->invoke($this->validationService, 'brussels is mentioned in lowercase.')); - $this->assertTrue($method->invoke($this->validationService, 'BeLgIuM is mentioned in mixed case.')); - $this->assertTrue($method->invoke($this->validationService, 'The FLEMISH government announced policies.')); - $this->assertTrue($method->invoke($this->validationService, 'n-va party policies were discussed.')); - $this->assertTrue($method->invoke($this->validationService, 'EUROPEAN union directives apply.')); - } - - public function test_rejects_content_without_belgian_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertFalse($method->invoke($this->validationService, 'This article discusses random topics.')); - $this->assertFalse($method->invoke($this->validationService, 'International news from other countries.')); - $this->assertFalse($method->invoke($this->validationService, 'Technology updates and innovations.')); - $this->assertFalse($method->invoke($this->validationService, 'Sports results from around the world.')); - $this->assertFalse($method->invoke($this->validationService, 'Entertainment news and celebrity gossip.')); - $this->assertFalse($method->invoke($this->validationService, 'Weather forecast for next week.')); - $this->assertFalse($method->invoke($this->validationService, 'Stock market analysis and trends.')); - } - - public function test_keyword_matching_in_longer_text(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $longText = ' - This is a comprehensive article about various topics. - It covers international relations, global economics, and regional policies. - However, it specifically mentions that Antwerp hosted a major conference - last week with participants from around the world. The event was - considered highly successful and will likely be repeated next year. - '; - - $this->assertTrue($method->invoke($this->validationService, $longText)); - - $longTextWithoutKeywords = ' - This is a comprehensive article about various topics. - It covers international relations, global finance, and commercial matters. - The conference was held in a major international city and attracted - participants from around the world. The event was considered highly - successful and will likely be repeated next year. - '; - - $this->assertFalse($method->invoke($this->validationService, $longTextWithoutKeywords)); - } - - public function test_empty_content_returns_false(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertFalse($method->invoke($this->validationService, '')); - $this->assertFalse($method->invoke($this->validationService, ' ')); - $this->assertFalse($method->invoke($this->validationService, "\n\n\t")); - } - - /** - * Test comprehensive keyword coverage to ensure all expected keywords work - */ - public function test_all_keywords_are_functional(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $expectedKeywords = [ - // Political parties and leaders - 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', - 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', - - // Belgian locations and institutions - 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', - 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', - 'parliament', 'government', 'minister', 'policy', 'law', 'legislation', - - // Common Belgian news topics - 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', - 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' - ]; - - foreach ($expectedKeywords as $keyword) { - $testContent = "This article contains the keyword: {$keyword}."; - $result = $method->invoke($this->validationService, $testContent); - - $this->assertTrue($result, "Keyword '{$keyword}' should match but didn't"); - } - } - - public function test_partial_keyword_matches_work(): void - { - $method = $this->getValidateByKeywordsMethod(); - - // Keywords should match when they appear as part of larger words or phrases - $this->assertTrue($method->invoke($this->validationService, 'Anti-government protesters gathered.')); - $this->assertTrue($method->invoke($this->validationService, 'The policeman directed traffic.')); - $this->assertTrue($method->invoke($this->validationService, 'Educational reforms are needed.')); - $this->assertTrue($method->invoke($this->validationService, 'Economic growth accelerated.')); - $this->assertTrue($method->invoke($this->validationService, 'The European directive was implemented.')); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/ValidationServiceTest.php b/tests/Unit/Services/ValidationServiceTest.php deleted file mode 100644 index 97df0b9..0000000 --- a/tests/Unit/Services/ValidationServiceTest.php +++ /dev/null @@ -1,164 +0,0 @@ -createArticleFetcher(); - $this->validationService = new ValidationService($articleFetcher); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_validate_returns_article_with_validation_status(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Test content with Belgium news', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending' - ]); - - $result = $this->validationService->validate($article); - - $this->assertInstanceOf(Article::class, $result); - $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); - } - - public function test_validate_marks_article_invalid_when_missing_data(): void - { - // Mock HTTP requests to return HTML without article content - Http::fake([ - 'https://invalid-url-without-parser.com/article' => Http::response('Empty', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://invalid-url-without-parser.com/article', - 'approval_status' => 'pending' - ]); - - $result = $this->validationService->validate($article); - - $this->assertEquals('rejected', $result->approval_status); - } - - public function test_validate_with_supported_article_content(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending' - ]); - - $result = $this->validationService->validate($article); - - // Since we can't fetch real content in tests, it should be marked rejected - $this->assertEquals('rejected', $result->approval_status); - } - - public function test_validate_updates_article_in_database(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending' - ]); - - $originalId = $article->id; - - $this->validationService->validate($article); - - // Check that the article was updated in the database - $updatedArticle = Article::find($originalId); - $this->assertContains($updatedArticle->approval_status, ['pending', 'approved', 'rejected']); - } - - public function test_validate_handles_article_with_existing_validation(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'approved' - ]); - - $originalApprovalStatus = $article->approval_status; - - $result = $this->validationService->validate($article); - - // Should re-validate - status may change based on content validation - $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); - } - - public function test_validate_keyword_checking_logic(): void - { - // Mock HTTP requests with content that contains Belgian keywords - Http::fake([ - 'https://example.com/article-about-bart-de-wever' => Http::response( - '
Article about Bart De Wever and Belgian politics
', - 200 - ) - ]); - - $feed = Feed::factory()->create(); - - // Create an article that would match the validation keywords if content was available - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article-about-bart-de-wever', - 'approval_status' => 'pending' - ]); - - $result = $this->validationService->validate($article); - - // The service looks for keywords in the full_article content - // Since we can't fetch real content, it will be marked rejected - $this->assertEquals('rejected', $result->approval_status); - } -} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 9e105b4..0000000 --- a/vite.config.js +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, - }), - ], - server: { - host: '0.0.0.0', - port: 5173, - strictPort: true, - cors: true, - hmr: { - host: 'localhost', - }, - }, -});