is_array($rule))); $allowedMatch = config('collections.smart_rules.allowed_match', ['all', 'any']); $allowedSort = config('collections.smart_rules.allowed_sort', ['newest', 'oldest', 'popular']); $allowedFields = config('collections.smart_rules.allowed_fields', []); $maxRules = (int) config('collections.smart_rules.max_rules', 8); if (! in_array($match, $allowedMatch, true)) { throw ValidationException::withMessages([ 'smart_rules_json.match' => 'Smart collections must use either all or any matching.', ]); } if (! in_array($sort, $allowedSort, true)) { throw ValidationException::withMessages([ 'smart_rules_json.sort' => 'Choose a supported sort for the smart collection.', ]); } if ($rules === []) { throw ValidationException::withMessages([ 'smart_rules_json.rules' => 'Add at least one smart collection rule.', ]); } if (count($rules) > $maxRules) { throw ValidationException::withMessages([ 'smart_rules_json.rules' => 'Too many smart collection rules were submitted.', ]); } $sanitized = []; foreach ($rules as $index => $rule) { $field = strtolower(trim((string) ($rule['field'] ?? ''))); $operator = strtolower(trim((string) ($rule['operator'] ?? ''))); $value = $rule['value'] ?? null; if (! in_array($field, $allowedFields, true)) { throw ValidationException::withMessages([ "smart_rules_json.rules.$index.field" => 'This smart collection field is not supported.', ]); } $sanitized[] = [ 'field' => $field, 'operator' => $this->validateOperator($field, $operator, $index), 'value' => $this->sanitizeValue($field, $value, $index), ]; } return [ 'match' => $match, 'sort' => $sort, 'rules' => $sanitized, ]; } public function resolveArtworks(Collection $collection, bool $ownerView, int $perPage = 24): LengthAwarePaginator { $query = $this->queryForCollection($collection, $ownerView) ->with($this->artworkRelations()) ->select('artworks.*'); return $query->paginate($perPage)->withQueryString(); } public function preview(User $owner, array $rules, bool $ownerView = true, int $perPage = 12): LengthAwarePaginator { $query = $this->queryForOwner($owner, $rules, $ownerView) ->with($this->artworkRelations()) ->select('artworks.*'); return $query->paginate($perPage)->withQueryString(); } public function countMatching(Collection|User $subject, ?array $rules = null, bool $ownerView = true): int { $query = $subject instanceof Collection ? $this->queryForCollection($subject, $ownerView) : $this->queryForOwner($subject, $rules, $ownerView); return (int) $query->toBase()->getCountForPagination(); } public function firstArtwork(Collection $collection, bool $ownerView): ?Artwork { return $this->queryForCollection($collection, $ownerView) ->with($this->artworkRelations()) ->select('artworks.*') ->first(); } public function smartSummary(?array $rules): ?string { if (! is_array($rules) || empty($rules['rules'])) { return null; } $glue = ($rules['match'] ?? 'all') === 'any' ? ' or ' : ' and '; $parts = []; foreach ((array) $rules['rules'] as $rule) { $field = $rule['field'] ?? null; $value = $rule['value'] ?? null; if ($field === 'created_at' && is_array($value)) { $from = $value['from'] ?? null; $to = $value['to'] ?? null; if ($from && $to) { $parts[] = sprintf('created between %s and %s', $from, $to); } continue; } if ($field === 'is_featured') { $parts[] = ((bool) $value) ? 'marked as featured artworks' : 'not marked as featured artworks'; continue; } if ($field === 'is_mature') { $parts[] = ((bool) $value) ? 'marked as mature artworks' : 'not marked as mature artworks'; continue; } if (is_string($value) && $value !== '') { $label = match ($field) { 'tags' => 'tagged ' . $value, 'category' => 'in category ' . $value, 'subcategory' => 'in subcategory ' . $value, 'medium' => 'in medium ' . $value, 'style' => 'matching style ' . $value, 'color' => 'using color palette ' . $value, 'ai_tag' => 'matching AI tag ' . $value, default => $value, }; $parts[] = $label; } } if ($parts === []) { return null; } return 'Includes artworks ' . implode($glue, $parts) . '.'; } public function ruleOptionsForOwner(User $owner): array { $tagOptions = DB::table('artwork_tag as at') ->join('artworks as a', 'a.id', '=', 'at.artwork_id') ->join('tags as t', 't.id', '=', 'at.tag_id') ->where('a.user_id', $owner->id) ->whereNull('a.deleted_at') ->where('t.is_active', true) ->groupBy('t.slug', 't.name') ->orderByRaw('COUNT(*) DESC') ->limit(20) ->get(['t.slug', 't.name']) ->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name]) ->all(); $rootCategories = DB::table('artwork_category as ac') ->join('artworks as a', 'a.id', '=', 'ac.artwork_id') ->join('categories as c', 'c.id', '=', 'ac.category_id') ->where('a.user_id', $owner->id) ->whereNull('a.deleted_at') ->where('c.is_active', true) ->whereNull('c.parent_id') ->groupBy('c.slug', 'c.name') ->orderByRaw('COUNT(*) DESC') ->limit(20) ->get(['c.slug', 'c.name']) ->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name]) ->all(); $subcategories = DB::table('artwork_category as ac') ->join('artworks as a', 'a.id', '=', 'ac.artwork_id') ->join('categories as c', 'c.id', '=', 'ac.category_id') ->where('a.user_id', $owner->id) ->whereNull('a.deleted_at') ->where('c.is_active', true) ->whereNotNull('c.parent_id') ->groupBy('c.slug', 'c.name') ->orderByRaw('COUNT(*) DESC') ->limit(20) ->get(['c.slug', 'c.name']) ->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name]) ->all(); $mediumOptions = DB::table('artwork_category as ac') ->join('artworks as a', 'a.id', '=', 'ac.artwork_id') ->join('categories as c', 'c.id', '=', 'ac.category_id') ->join('content_types as ct', 'ct.id', '=', 'c.content_type_id') ->where('a.user_id', $owner->id) ->whereNull('a.deleted_at') ->groupBy('ct.slug', 'ct.name') ->orderBy('ct.name') ->get(['ct.slug', 'ct.name']) ->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name]) ->all(); $styleOptions = $this->aiTagOptionsForOwner($owner, (array) config('collections.smart_rules.style_terms', [])); $colorOptions = $this->aiTagOptionsForOwner($owner, (array) config('collections.smart_rules.color_terms', [])); return [ 'fields' => [ ['value' => 'tags', 'label' => 'Tag'], ['value' => 'category', 'label' => 'Category'], ['value' => 'subcategory', 'label' => 'Subcategory'], ['value' => 'medium', 'label' => 'Medium'], ['value' => 'style', 'label' => 'Style'], ['value' => 'color', 'label' => 'Color palette'], ['value' => 'ai_tag', 'label' => 'AI tag'], ['value' => 'created_at', 'label' => 'Created date'], ['value' => 'is_featured', 'label' => 'Featured artwork'], ['value' => 'is_mature', 'label' => 'Mature artwork'], ], 'tag_options' => $tagOptions, 'category_options' => $rootCategories, 'subcategory_options' => $subcategories, 'medium_options' => $mediumOptions, 'style_options' => $styleOptions, 'color_options' => $colorOptions, 'sort_options' => [ ['value' => 'newest', 'label' => 'Newest first'], ['value' => 'oldest', 'label' => 'Oldest first'], ['value' => 'popular', 'label' => 'Most viewed'], ], ]; } public function queryForCollection(Collection $collection, bool $ownerView): Builder { return $this->queryForOwner($collection->user, $collection->smart_rules_json, $ownerView); } public function queryForOwner(User $owner, ?array $rules, bool $ownerView): Builder { if ($rules === null) { throw ValidationException::withMessages([ 'smart_rules_json' => 'Smart collections require at least one valid rule.', ]); } $sanitized = $this->sanitizeRules($rules); $query = Artwork::query() ->where('artworks.user_id', $owner->id) ->whereNull('artworks.deleted_at'); if (! $ownerView) { $query->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNotNull('artworks.published_at') ->where('artworks.published_at', '<=', now()); } $method = ($sanitized['match'] ?? 'all') === 'any' ? 'orWhere' : 'where'; $query->where(function (Builder $outer) use ($sanitized, $method): void { foreach ((array) ($sanitized['rules'] ?? []) as $index => $rule) { $callback = function (Builder $builder) use ($rule): void { $this->applyRule($builder, $rule); }; if ($index === 0 || $method === 'where') { $outer->where($callback); } else { $outer->orWhere($callback); } } }); return $this->applySort($query, (string) ($sanitized['sort'] ?? Collection::SORT_NEWEST)); } /** * @return array */ private function artworkRelations(): array { return [ 'user:id,name,username', 'stats:artwork_id,views,downloads,favorites', 'categories' => function ($query) { $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') ->with(['contentType:id,slug,name']); }, ]; } /** * @param array{field:string,operator:string,value:mixed} $rule */ private function applyRule(Builder $query, array $rule): void { $field = $rule['field']; $value = $rule['value']; match ($field) { 'tags' => $query->whereHas('tags', function (Builder $builder) use ($value): void { $builder->where('tags.slug', (string) $value) ->orWhere('tags.name', 'like', '%' . (string) $value . '%'); }), 'category' => $query->whereHas('categories', function (Builder $builder) use ($value): void { $builder->where('categories.slug', (string) $value) ->whereNull('categories.parent_id'); }), 'subcategory' => $query->whereHas('categories', function (Builder $builder) use ($value): void { $builder->where('categories.slug', (string) $value) ->whereNotNull('categories.parent_id'); }), 'medium' => $query->whereHas('categories.contentType', function (Builder $builder) use ($value): void { $builder->where('content_types.slug', (string) $value); }), 'style' => $this->applyAiPivotTagRule($query, (string) $value), 'color' => $this->applyAiPivotTagRule($query, (string) $value), 'ai_tag' => $query->where(function (Builder $builder) use ($value): void { $builder->where('artworks.blip_caption', 'like', '%' . (string) $value . '%') ->orWhere('artworks.clip_tags_json', 'like', '%' . (string) $value . '%') ->orWhere('artworks.yolo_objects_json', 'like', '%' . (string) $value . '%'); }), 'created_at' => $query->whereBetween('artworks.created_at', [ Carbon::parse((string) ($value['from'] ?? now()->subYear()->toDateString()))->startOfDay(), Carbon::parse((string) ($value['to'] ?? now()->toDateString()))->endOfDay(), ]), 'is_featured' => $this->applyFeaturedRule($query, (bool) $value), 'is_mature' => $query->where('artworks.is_mature', (bool) $value), default => null, }; } private function applyFeaturedRule(Builder $query, bool $value): void { if ($value) { $query->whereExists(function ($sub): void { $sub->selectRaw('1') ->from('artwork_features as af') ->whereColumn('af.artwork_id', 'artworks.id') ->where('af.is_active', true) ->whereNull('af.deleted_at'); }); return; } $query->whereNotExists(function ($sub): void { $sub->selectRaw('1') ->from('artwork_features as af') ->whereColumn('af.artwork_id', 'artworks.id') ->where('af.is_active', true) ->whereNull('af.deleted_at'); }); } private function applySort(Builder $query, string $sort): Builder { return match ($sort) { Collection::SORT_OLDEST => $query->orderBy('artworks.published_at')->orderBy('artworks.id'), Collection::SORT_POPULAR => $query->orderByDesc('artworks.view_count')->orderByDesc('artworks.id'), default => $query->orderByDesc('artworks.published_at')->orderByDesc('artworks.id'), }; } private function validateOperator(string $field, string $operator, int $index): string { $allowed = match ($field) { 'created_at' => ['between'], 'is_featured', 'is_mature' => ['equals'], default => ['contains', 'equals'], }; if (! in_array($operator, $allowed, true)) { throw ValidationException::withMessages([ "smart_rules_json.rules.$index.operator" => 'This operator is not supported for the selected field.', ]); } return $operator; } private function sanitizeValue(string $field, mixed $value, int $index): mixed { if ($field === 'created_at') { if (! is_array($value) || empty($value['from']) || empty($value['to'])) { throw ValidationException::withMessages([ "smart_rules_json.rules.$index.value" => 'Provide a valid start and end date for this rule.', ]); } return [ 'from' => Carbon::parse((string) $value['from'])->toDateString(), 'to' => Carbon::parse((string) $value['to'])->toDateString(), ]; } if ($field === 'is_featured' || $field === 'is_mature') { return (bool) $value; } $stringValue = trim((string) $value); if ($stringValue === '' || mb_strlen($stringValue) > 80) { throw ValidationException::withMessages([ "smart_rules_json.rules.$index.value" => 'Provide a shorter value for this smart rule.', ]); } return $stringValue; } private function applyAiPivotTagRule(Builder $query, string $value): void { $query->whereHas('tags', function (Builder $builder) use ($value): void { $builder->where('artwork_tag.source', 'ai') ->where(function (Builder $match) use ($value): void { $match->where('tags.slug', str($value)->slug()->value()) ->orWhere('tags.name', 'like', '%' . $value . '%'); }); }); } /** * @param array $allowedTerms * @return array */ private function aiTagOptionsForOwner(User $owner, array $allowedTerms): array { if ($allowedTerms === []) { return []; } $allowedSlugs = collect($allowedTerms) ->map(static fn (string $term) => str($term)->slug()->value()) ->filter() ->unique() ->values(); if ($allowedSlugs->isEmpty()) { return []; } return DB::table('artwork_tag as at') ->join('artworks as a', 'a.id', '=', 'at.artwork_id') ->join('tags as t', 't.id', '=', 'at.tag_id') ->where('a.user_id', $owner->id) ->whereNull('a.deleted_at') ->where('at.source', 'ai') ->whereIn('t.slug', $allowedSlugs->all()) ->groupBy('t.slug', 't.name') ->orderByRaw('COUNT(*) DESC') ->orderBy('t.name') ->get(['t.slug', 't.name']) ->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name]) ->all(); } }