map(fn (mixed $file): array => [ 'name' => trim((string) data_get($file, 'name', '')), ]) ->filter(fn (array $file): bool => $file['name'] !== '') ->values(); if ($normalizedFiles->isEmpty()) { throw ValidationException::withMessages([ 'files' => ['Choose at least one file for the upload queue.'], ]); } if ($normalizedFiles->count() > 50) { throw ValidationException::withMessages([ 'files' => ['Bulk upload is limited to 50 files per batch in v1.'], ]); } $normalizedDefaults = $this->normalizeDefaults($defaults); $batch = DB::transaction(function () use ($user, $normalizedFiles, $normalizedDefaults, $name): UploadBatch { $batch = UploadBatch::query()->create([ 'user_id' => (int) $user->id, 'name' => $this->normalizeBatchName($name, $normalizedFiles->count()), 'status' => UploadBatch::STATUS_UPLOADING, 'total_items' => $normalizedFiles->count(), 'defaults_json' => $normalizedDefaults === [] ? null : $normalizedDefaults, ]); foreach ($normalizedFiles as $file) { $draft = $this->artworkDrafts->createDraft( $user, $this->titleFromFilename($file['name']), null, Arr::get($normalizedDefaults, 'category_id'), (bool) Arr::get($normalizedDefaults, 'is_mature', false), Arr::get($normalizedDefaults, 'group') ); $artwork = Artwork::query()->findOrFail($draft->artworkId); $artwork->forceFill([ 'visibility' => Arr::get($normalizedDefaults, 'visibility', Artwork::VISIBILITY_PUBLIC), 'is_public' => false, 'artwork_status' => 'draft', ])->saveQuietly(); $defaultTags = (array) Arr::get($normalizedDefaults, 'tags', []); if ($defaultTags !== []) { $this->tags->syncStudioTags($artwork, $defaultTags); } $batch->items()->create([ 'user_id' => (int) $user->id, 'artwork_id' => (int) $artwork->id, 'original_filename' => $file['name'], 'status' => UploadBatchItem::STATUS_UPLOADED, 'processing_stage' => UploadBatchItem::STAGE_QUEUED, 'metadata_completeness' => $this->metadataCompleteness($artwork, false, false), 'is_ready_to_publish' => false, ]); } return $batch; }); return $this->refreshBatch($batch->id); } public function listPayload(User $user, array $filters = []): array { $requestedBatchId = (int) ($filters['batch_id'] ?? 0); $statusFilter = Str::lower(trim((string) ($filters['status'] ?? 'all'))); $sort = Str::lower(trim((string) ($filters['sort'] ?? 'newest'))); $recentBatches = UploadBatch::query() ->where('user_id', (int) $user->id) ->latest('id') ->limit(12) ->get(); $currentBatch = $requestedBatchId > 0 ? $recentBatches->firstWhere('id', $requestedBatchId) : $recentBatches->first(); if ($currentBatch) { $currentBatch = $this->refreshBatch((int) $currentBatch->id); } $items = $currentBatch ? $currentBatch->items ->loadMissing(['artwork.categories.contentType', 'artwork.tags']) ->map(fn (UploadBatchItem $item): array => $this->presentItem($this->refreshItem((int) $item->id))) : collect(); $filteredItems = $this->applyFiltersAndSorting($items, $statusFilter, $sort)->values(); $recentBatchPayloads = $recentBatches ->map(fn (UploadBatch $batch): UploadBatch => $batch->relationLoaded('items') ? $batch : $this->refreshBatch((int) $batch->id)) ->map(fn (UploadBatch $batch): array => $this->presentBatch($batch)) ->values() ->all(); return [ 'filters' => [ 'batch_id' => $currentBatch?->id, 'status' => $statusFilter, 'sort' => $sort, ], 'batches' => $recentBatchPayloads, 'current_batch' => $currentBatch ? $this->presentBatch($currentBatch) : null, 'items' => $filteredItems->all(), 'status_options' => [ ['value' => 'all', 'label' => 'All'], ['value' => UploadBatchItem::STATUS_PROCESSING, 'label' => 'Processing'], ['value' => UploadBatchItem::STATUS_NEEDS_METADATA, 'label' => 'Needs metadata'], ['value' => UploadBatchItem::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'], ['value' => UploadBatchItem::STATUS_READY, 'label' => 'Ready'], ['value' => UploadBatchItem::STATUS_FAILED, 'label' => 'Failed'], ['value' => UploadBatchItem::STATUS_PUBLISHED, 'label' => 'Published'], ], 'sort_options' => [ ['value' => 'newest', 'label' => 'Newest first'], ['value' => 'oldest', 'label' => 'Oldest first'], ['value' => 'filename', 'label' => 'Filename'], ['value' => 'status', 'label' => 'Status'], ['value' => 'ready', 'label' => 'Ready first'], ['value' => 'failed', 'label' => 'Failed first'], ], ]; } public function markItemProcessingQueued(int $itemId): UploadBatchItem { $item = $this->itemQuery()->findOrFail($itemId); $item->forceFill([ 'status' => UploadBatchItem::STATUS_PROCESSING, 'processing_stage' => UploadBatchItem::STAGE_THUMBNAILS, 'error_code' => null, 'error_message' => null, 'uploaded_at' => $item->uploaded_at ?: now(), ])->save(); $this->refreshBatch((int) $item->upload_batch_id); return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item; } public function markItemMediaProcessed(int $itemId): UploadBatchItem { $item = $this->itemQuery()->findOrFail($itemId); $item->forceFill([ 'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK, 'error_code' => null, 'error_message' => null, 'processed_at' => now(), ])->save(); $refreshed = $this->refreshItem((int) $item->id); $this->refreshBatch((int) $item->upload_batch_id); return $refreshed; } public function markItemFailed(int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem { $item = $this->itemQuery()->findOrFail($itemId); $item->forceFill([ 'status' => UploadBatchItem::STATUS_FAILED, 'error_code' => $this->nullableString($errorCode), 'error_message' => $this->nullableText($errorMessage), 'is_ready_to_publish' => false, ])->save(); $this->refreshBatch((int) $item->upload_batch_id); return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item; } public function markItemFailedForUser(User $user, int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem { $item = $this->ownedItems($user, [$itemId])->first(); if (! $item) { throw ValidationException::withMessages([ 'item' => ['Upload queue item not found.'], ]); } return $this->markItemFailed((int) $item->id, $errorCode, $errorMessage); } public function refreshItem(int $itemId): UploadBatchItem { $item = $this->itemQuery()->findOrFail($itemId); $state = $this->evaluateItemState($item); $item->forceFill([ 'status' => $state['status'], 'processing_stage' => $state['processing_stage'], 'metadata_completeness' => $state['metadata_completeness'], 'is_ready_to_publish' => $state['is_ready_to_publish'], 'published_at' => $state['published_at'], 'processed_at' => $state['processed_at'], ])->saveQuietly(); return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item; } public function refreshBatch(int $batchId): UploadBatch { $batch = UploadBatch::query() ->with(['items.artwork.categories.contentType', 'items.artwork.tags']) ->findOrFail($batchId); $items = $batch->items->map(fn (UploadBatchItem $item): UploadBatchItem => $this->refreshItem((int) $item->id)); $summary = $this->summarizeItems($items); $batch->forceFill([ 'status' => $summary['status'], 'total_items' => $summary['total_items'], 'processed_items' => $summary['processed_items'], 'failed_items' => $summary['failed_items'], 'published_items' => $summary['published_items'], ])->saveQuietly(); return $batch->fresh(['items.artwork.categories.contentType', 'items.artwork.tags']) ?? $batch; } public function bulkAction(User $user, string $action, array $itemIds, array $params = []): array { $items = $this->ownedItems($user, $itemIds); if ($items->isEmpty()) { return [ 'success' => 0, 'failed' => count($itemIds), 'errors' => ['No owned upload queue items were found.'], ]; } $success = 0; $failed = 0; $errors = []; foreach ($items as $item) { try { match ($action) { 'publish' => $this->publishItem($item), 'delete' => $this->deleteItem($item), 'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)), 'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])), 'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')), 'generate_ai' => $this->retryProcessing($item), default => throw ValidationException::withMessages([ 'action' => ['Unsupported upload queue action.'], ]), }; $success++; } catch (ValidationException $exception) { $failed++; $errors[] = collect($exception->errors())->flatten()->first() ?: 'Action failed.'; } catch (\Throwable $exception) { $failed++; $errors[] = $exception->getMessage(); } } $batchIds = $items->pluck('upload_batch_id')->unique()->filter()->all(); foreach ($batchIds as $batchId) { $this->refreshBatch((int) $batchId); } return [ 'success' => $success, 'failed' => $failed, 'errors' => $errors, ]; } public function retryProcessingForUser(User $user, int $itemId): UploadBatchItem { $item = $this->ownedItems($user, [$itemId])->first(); if (! $item) { throw ValidationException::withMessages([ 'item' => ['Upload queue item not found.'], ]); } return $this->retryProcessing($item); } private function retryProcessing(UploadBatchItem $item): UploadBatchItem { $artwork = $item->artwork; if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') { throw ValidationException::withMessages([ 'item' => ['This item cannot be retried safely. Re-upload the original file instead.'], ]); } $item->forceFill([ 'status' => UploadBatchItem::STATUS_PROCESSING, 'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK, 'error_code' => null, 'error_message' => null, 'is_ready_to_publish' => false, ])->save(); AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit(); DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit(); GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit(); AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit(); return $this->refreshItem((int) $item->id); } private function publishItem(UploadBatchItem $item): void { $item = $this->refreshItem((int) $item->id); if (! $item->is_ready_to_publish || ! $item->artwork) { throw ValidationException::withMessages([ 'item' => ['Only ready queue items can be published.'], ]); } $artwork = $item->artwork; $artwork->forceFill([ 'is_approved' => true, 'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC, ])->saveQuietly(); $this->publication->publishNow($artwork->fresh() ?? $artwork); $item->forceFill([ 'status' => UploadBatchItem::STATUS_PUBLISHED, 'processing_stage' => UploadBatchItem::STAGE_FINALIZED, 'is_ready_to_publish' => false, 'published_at' => now(), ])->save(); } private function deleteItem(UploadBatchItem $item): void { if ($item->artwork && ! $item->artwork->trashed()) { $item->artwork->delete(); } $item->forceFill([ 'status' => UploadBatchItem::STATUS_DELETED, 'processing_stage' => UploadBatchItem::STAGE_FINALIZED, 'is_ready_to_publish' => false, ])->save(); } private function applyCategory(UploadBatchItem $item, int $categoryId): void { if ($categoryId <= 0 || ! $item->artwork) { throw ValidationException::withMessages([ 'category_id' => ['Choose a valid category.'], ]); } $item->artwork->categories()->sync([$categoryId]); $this->refreshItem((int) $item->id); } private function applyTags(UploadBatchItem $item, array $tags): void { if (! $item->artwork) { throw ValidationException::withMessages([ 'tags' => ['Artwork not found for this queue item.'], ]); } $normalizedTags = collect($tags) ->map(fn (mixed $tag): string => trim((string) $tag)) ->filter() ->values(); if ($normalizedTags->isEmpty()) { throw ValidationException::withMessages([ 'tags' => ['Enter at least one tag to apply.'], ]); } $mergedTags = collect($item->artwork->tags()->pluck('tags.slug')->all()) ->merge($normalizedTags) ->map(fn (string $tag): string => trim($tag)) ->filter() ->unique() ->values() ->all(); $this->tags->syncStudioTags($item->artwork->fresh(['tags']) ?? $item->artwork, $mergedTags); $this->refreshItem((int) $item->id); } private function setVisibility(UploadBatchItem $item, string $visibility): void { if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true) || ! $item->artwork) { throw ValidationException::withMessages([ 'visibility' => ['Choose a valid visibility.'], ]); } $item->artwork->forceFill([ 'visibility' => $visibility, 'is_public' => false, ])->saveQuietly(); $this->refreshItem((int) $item->id); } private function ownedItems(User $user, array $itemIds): EloquentCollection { $normalizedIds = collect($itemIds) ->map(fn (mixed $id): int => (int) $id) ->filter(fn (int $id): bool => $id > 0) ->values() ->all(); return $this->itemQuery() ->where('user_id', (int) $user->id) ->whereIn('id', $normalizedIds) ->get(); } private function itemQuery() { return UploadBatchItem::query()->with(['artwork.categories.contentType', 'artwork.tags']); } private function applyFiltersAndSorting(Collection $items, string $statusFilter, string $sort): Collection { $filtered = $statusFilter === 'all' ? $items : $items->filter(fn (array $item): bool => (string) ($item['status'] ?? '') === $statusFilter); return match ($sort) { 'oldest' => $filtered->sortBy('created_at'), 'filename' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['original_filename'] ?? ''))), 'status' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['status'] ?? ''))), 'ready' => $filtered->sortByDesc(fn (array $item): int => (int) ($item['is_ready_to_publish'] ?? false)), 'failed' => $filtered->sortByDesc(fn (array $item): int => (int) ((string) ($item['status'] ?? '') === UploadBatchItem::STATUS_FAILED)), default => $filtered->sortByDesc('created_at'), }; } private function summarizeItems(EloquentCollection $items): array { $statusCounts = $items->groupBy('status')->map->count(); $activeCount = (int) ($statusCounts[UploadBatchItem::STATUS_UPLOADED] ?? 0) + (int) ($statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0); $failedCount = (int) ($statusCounts[UploadBatchItem::STATUS_FAILED] ?? 0); $publishedCount = (int) ($statusCounts[UploadBatchItem::STATUS_PUBLISHED] ?? 0); $processedCount = $items->filter(fn (UploadBatchItem $item): bool => in_array((string) $item->status, [ UploadBatchItem::STATUS_NEEDS_METADATA, UploadBatchItem::STATUS_NEEDS_REVIEW, UploadBatchItem::STATUS_READY, UploadBatchItem::STATUS_FAILED, UploadBatchItem::STATUS_PUBLISHED, UploadBatchItem::STATUS_DELETED, ], true))->count(); $status = UploadBatch::STATUS_COMPLETED; if ($activeCount > 0) { $status = $statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0 ? UploadBatch::STATUS_PROCESSING : UploadBatch::STATUS_UPLOADING; } elseif ($failedCount > 0) { $status = UploadBatch::STATUS_COMPLETED_WITH_ERRORS; } return [ 'status' => $status, 'total_items' => $items->count(), 'processed_items' => $processedCount, 'failed_items' => $failedCount, 'published_items' => $publishedCount, 'ready_items' => (int) ($statusCounts[UploadBatchItem::STATUS_READY] ?? 0), 'processing_items' => $activeCount, 'needs_metadata_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_METADATA] ?? 0), 'needs_review_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_REVIEW] ?? 0), ]; } private function evaluateItemState(UploadBatchItem $item): array { $artwork = $item->artwork; $isDeleted = (string) $item->status === UploadBatchItem::STATUS_DELETED || ($artwork?->trashed() ?? false); $isPublished = $artwork && (string) ($artwork->artwork_status ?? '') === 'published' && $artwork->published_at !== null; $hasProcessedMedia = $artwork && trim((string) ($artwork->file_name ?? '')) !== '' && trim((string) ($artwork->file_name ?? '')) !== 'pending' && trim((string) ($artwork->file_path ?? '')) !== '' && trim((string) ($artwork->hash ?? '')) !== ''; $title = trim((string) ($artwork?->title ?? '')); $hasTitle = $title !== ''; $hasCategory = (bool) $artwork?->categories?->first(); $maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR)); $maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED)); $aiStatus = Str::lower((string) ($artwork?->ai_status ?? '')); $visionEnabled = (bool) config('vision.enabled', true); $maturityPending = $visionEnabled && in_array($maturityAiStatus, [ ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED, ], true); $maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED; $needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed; $needsMetadata = ! $hasTitle || ! $hasCategory; $blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null); $isReadyToPublish = ! $isDeleted && ! $isPublished && ! $blockingUploadFailure && $hasProcessedMedia && ! $maturityPending && ! $needsReview && ! $needsMetadata && $artwork && (int) $artwork->user_id === (int) $item->user_id; $status = match (true) { $isDeleted => UploadBatchItem::STATUS_DELETED, $isPublished => UploadBatchItem::STATUS_PUBLISHED, $blockingUploadFailure => UploadBatchItem::STATUS_FAILED, ! $hasProcessedMedia || $maturityPending => UploadBatchItem::STATUS_PROCESSING, $needsReview => UploadBatchItem::STATUS_NEEDS_REVIEW, $needsMetadata => UploadBatchItem::STATUS_NEEDS_METADATA, $isReadyToPublish => UploadBatchItem::STATUS_READY, default => UploadBatchItem::STATUS_PROCESSING, }; $processingStage = match (true) { in_array($status, [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED, UploadBatchItem::STATUS_FAILED], true) => UploadBatchItem::STAGE_FINALIZED, ! $hasProcessedMedia && (string) $item->processing_stage === UploadBatchItem::STAGE_QUEUED => UploadBatchItem::STAGE_QUEUED, ! $hasProcessedMedia => UploadBatchItem::STAGE_THUMBNAILS, $maturityPending => UploadBatchItem::STAGE_MATURITY_CHECK, in_array($aiStatus, ['queued', 'processing'], true) => UploadBatchItem::STAGE_METADATA_SUGGESTIONS, default => UploadBatchItem::STAGE_FINALIZED, }; return [ 'status' => $status, 'processing_stage' => $processingStage, 'metadata_completeness' => $this->metadataCompleteness($artwork, $hasProcessedMedia, ! $maturityPending && ! $maturityFailed), 'is_ready_to_publish' => $isReadyToPublish, 'published_at' => $isPublished ? ($item->published_at ?: now()) : null, 'processed_at' => $hasProcessedMedia ? ($item->processed_at ?: now()) : $item->processed_at, ]; } private function presentBatch(UploadBatch $batch): array { $summary = $this->summarizeItems($batch->items); return [ 'id' => (int) $batch->id, 'name' => $batch->name, 'status' => $summary['status'], 'total_items' => $summary['total_items'], 'processed_items' => $summary['processed_items'], 'failed_items' => $summary['failed_items'], 'published_items' => $summary['published_items'], 'ready_items' => $summary['ready_items'], 'processing_items' => $summary['processing_items'], 'needs_metadata_items' => $summary['needs_metadata_items'], 'needs_review_items' => $summary['needs_review_items'], 'defaults' => $batch->defaults_json ?? [], 'created_at' => $this->iso($batch->created_at), 'updated_at' => $this->iso($batch->updated_at), ]; } private function presentItem(UploadBatchItem $item): array { $artwork = $item->artwork; $state = $this->evaluateItemState($item); $maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR)); $maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED)); $missing = []; if (! $artwork || trim((string) ($artwork->file_path ?? '')) === '' || trim((string) ($artwork->hash ?? '')) === '') { $missing[] = 'Processing incomplete'; } if (! $artwork || trim((string) ($artwork->title ?? '')) === '') { $missing[] = 'Missing title'; } if (! $artwork?->categories?->first()) { $missing[] = 'Missing category'; } if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) { $missing[] = 'Needs maturity review'; } elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) { $missing[] = 'Maturity analysis pending'; } elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) { $missing[] = 'Maturity check failed'; } $canRetry = ! empty($artwork?->hash) && ! empty($artwork?->file_path) && ($state['status'] === UploadBatchItem::STATUS_FAILED || $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED); return [ 'id' => (int) $item->id, 'batch_id' => (int) $item->upload_batch_id, 'artwork_id' => $artwork?->id, 'title' => $artwork?->title ?: $this->titleFromFilename((string) $item->original_filename), 'original_filename' => $item->original_filename, 'status' => $state['status'], 'processing_stage' => $state['processing_stage'], 'metadata_completeness' => $state['metadata_completeness'], 'metadata_label' => $state['metadata_completeness'] . '% complete', 'is_ready_to_publish' => $state['is_ready_to_publish'], 'error_code' => $item->error_code, 'error_message' => $item->error_message, 'missing' => $missing, 'thumbnail_url' => $artwork?->thumbUrl('sm'), 'visibility' => $artwork?->visibility, 'maturity_status' => $maturityStatus, 'maturity_ai_status' => $maturityAiStatus, 'ai_status' => Str::lower((string) ($artwork?->ai_status ?? '')) ?: null, 'created_at' => $this->iso($item->created_at), 'updated_at' => $this->iso($item->updated_at), 'published_at' => $this->iso($item->published_at), 'edit_url' => $artwork ? route('studio.artworks.edit', ['id' => $artwork->id]) : null, 'public_url' => $artwork && $artwork->published_at ? route('art.show', ['id' => $artwork->id]) : null, 'actions' => [ 'can_edit' => $artwork !== null && $state['status'] !== UploadBatchItem::STATUS_DELETED, 'can_publish' => $state['is_ready_to_publish'], 'can_delete' => ! in_array($state['status'], [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED], true), 'can_retry_processing' => $canRetry, 'can_generate_ai' => $artwork !== null && trim((string) ($artwork->hash ?? '')) !== '', ], ]; } private function metadataCompleteness(?Artwork $artwork, bool $hasProcessedMedia, bool $maturityReady): int { $checks = [ $hasProcessedMedia, trim((string) ($artwork?->title ?? '')) !== '', (bool) $artwork?->categories?->first(), $maturityReady, ]; return (int) round((collect($checks)->filter()->count() / count($checks)) * 100); } private function normalizeDefaults(array $defaults): array { $visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC); if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true)) { $visibility = Artwork::VISIBILITY_PUBLIC; } return array_filter([ 'category_id' => ($categoryId = (int) ($defaults['category_id'] ?? 0)) > 0 ? $categoryId : null, 'tags' => collect((array) ($defaults['tags'] ?? [])) ->map(fn (mixed $tag): string => trim((string) $tag)) ->filter() ->values() ->all(), 'visibility' => $visibility, 'is_mature' => (bool) ($defaults['is_mature'] ?? false), 'group' => $this->nullableString($defaults['group'] ?? null), ], static fn (mixed $value): bool => $value !== null && $value !== []); } private function titleFromFilename(string $filename): string { $base = pathinfo($filename, PATHINFO_FILENAME); $normalized = Str::of($base) ->replace(['_', '-'], ' ') ->squish() ->trim(); return (string) ($normalized !== '' ? Str::limit((string) $normalized, 255, '') : 'Untitled artwork'); } private function normalizeBatchName(?string $name, int $count): string { $normalized = trim((string) $name); if ($normalized !== '') { return Str::limit($normalized, 160, ''); } return 'Upload Queue ' . now()->format('M j, Y g:i A') . ' (' . $count . ')'; } private function iso(CarbonInterface|string|null $value): ?string { if ($value instanceof CarbonInterface) { return $value->toIso8601String(); } if (is_string($value) && trim($value) !== '') { return $value; } return null; } private function nullableString(mixed $value): ?string { $normalized = trim((string) $value); return $normalized === '' ? null : $normalized; } private function nullableText(mixed $value): ?string { $normalized = trim((string) $value); return $normalized === '' ? null : Str::limit($normalized, 65535, ''); } }