diff --git a/app/Console/Commands/BackfillPluginCategories.php b/app/Console/Commands/BackfillPluginCategories.php new file mode 100644 index 000000000..3693ae591 --- /dev/null +++ b/app/Console/Commands/BackfillPluginCategories.php @@ -0,0 +1,75 @@ +option('dry-run'); + + $plugins = Plugin::query()->whereNull('category')->get(); + + if ($plugins->isEmpty()) { + $this->info('No plugins need a category backfilled.'); + + return self::SUCCESS; + } + + /** @var Collection $matched */ + $matched = new Collection; + /** @var Collection $unmatched */ + $unmatched = new Collection; + + foreach ($plugins as $plugin) { + $category = $classifier->classify($plugin); + + if ($category === null) { + $unmatched->push($plugin); + + continue; + } + + $matched->push([$plugin, $category]); + + if (! $dryRun) { + $plugin->update(['category' => $category]); + } + } + + if ($matched->isNotEmpty()) { + $this->table( + ['Plugin', 'Proposed Category'], + $matched->map(fn (array $row): array => [$row[0]->name, $row[1]->label()])->all() + ); + } + + if ($unmatched->isNotEmpty()) { + $this->newLine(); + $this->warn('No confident match — left uncategorized for a manual look:'); + $this->table(['Plugin'], $unmatched->map(fn (Plugin $plugin): array => [$plugin->name])->all()); + } + + $this->newLine(); + $this->info(sprintf('%d matched, %d left uncategorized.', $matched->count(), $unmatched->count())); + + if ($dryRun) { + $this->warn('Dry run — no changes were saved. Run again without --dry-run to apply.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Enums/PluginCategory.php b/app/Enums/PluginCategory.php new file mode 100644 index 000000000..80633f864 --- /dev/null +++ b/app/Enums/PluginCategory.php @@ -0,0 +1,27 @@ + 'Media', + self::Security => 'Security', + self::Connectivity => 'Connectivity', + self::Notifications => 'Notifications', + self::Payments => 'Payments', + self::Analytics => 'Analytics', + self::System => 'System', + }; + } +} diff --git a/app/Livewire/PluginDirectory.php b/app/Livewire/PluginDirectory.php index b0f2b5b9d..221c23278 100644 --- a/app/Livewire/PluginDirectory.php +++ b/app/Livewire/PluginDirectory.php @@ -2,9 +2,12 @@ namespace App\Livewire; +use App\Enums\PluginCategory; +use App\Enums\PluginType; use App\Models\Plugin; use App\Models\PluginBundle; use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; use Livewire\Attributes\Layout; @@ -19,6 +22,16 @@ class PluginDirectory extends Component { use WithPagination; + /** + * Filter value representing plugins with no `category` recorded. + */ + public const string CATEGORY_UNCATEGORIZED = 'uncategorized'; + + /** + * Filter value representing plugins with no `mobile_min_version` recorded. + */ + public const string MOBILE_VERSION_UNSPECIFIED = 'unspecified'; + #[Url] public string $search = ''; @@ -28,6 +41,32 @@ class PluginDirectory extends Component #[Url] public string $view = 'plugins'; + #[Url] + public string $type = ''; + + #[Url] + public string $category = ''; + + #[Url] + public string $mobileVersion = ''; + + /** + * Drop any `type`/`category` value that isn't a real enum case (a stale + * bookmark, a hand-edited URL) instead of crashing later on `::from()`. + */ + public function mount(): void + { + if ($this->type !== '' && PluginType::tryFrom($this->type) === null) { + $this->type = ''; + } + + if ($this->category !== '' + && $this->category !== self::CATEGORY_UNCATEGORIZED + && PluginCategory::tryFrom($this->category) === null) { + $this->category = ''; + } + } + public function updatedSearch(): void { $this->resetPage(); @@ -43,6 +82,21 @@ public function updatedView(): void $this->resetPage(); } + public function updatedType(): void + { + $this->resetPage(); + } + + public function updatedCategory(): void + { + $this->resetPage(); + } + + public function updatedMobileVersion(): void + { + $this->resetPage(); + } + public function clearSearch(): void { $this->search = ''; @@ -55,6 +109,32 @@ public function clearAuthor(): void $this->resetPage(); } + public function clearType(): void + { + $this->type = ''; + $this->resetPage(); + } + + public function clearCategory(): void + { + $this->category = ''; + $this->resetPage(); + } + + public function clearMobileVersion(): void + { + $this->mobileVersion = ''; + $this->resetPage(); + } + + public function clearFilters(): void + { + $this->type = ''; + $this->category = ''; + $this->mobileVersion = ''; + $this->resetPage(); + } + public function showPlugins(): void { $this->view = 'plugins'; @@ -75,15 +155,39 @@ public function render(): View $plugins = Plugin::query() ->approved() ->where('is_active', true) - ->when($this->search, function ($query): void { - $query->where(function ($q): void { + ->when($this->search, function (Builder $query): void { + $query->where(function (Builder $q): void { $q->where('name', 'like', "%{$this->search}%") ->orWhere('description', 'like', "%{$this->search}%"); }); }) - ->when($this->author, function ($query): void { + ->when($this->author, function (Builder $query): void { $query->where('user_id', $this->author); }) + ->when($this->type !== '', function (Builder $query): void { + $query->where('type', $this->type); + }) + ->when($this->category !== '', function (Builder $query): void { + if ($this->category === self::CATEGORY_UNCATEGORIZED) { + $query->whereNull('category'); + + return; + } + + $query->where('category', $this->category); + }) + ->when($this->mobileVersion !== '', function (Builder $query): void { + if ($this->mobileVersion === self::MOBILE_VERSION_UNSPECIFIED) { + $query->whereNull('mobile_min_version'); + + return; + } + + $query->where(function (Builder $q): void { + $q->where('mobile_min_version', 'like', "{$this->mobileVersion}.%") + ->orWhere('mobile_min_version', $this->mobileVersion); + }); + }) ->orderByDesc('featured') ->latest() ->paginate(12); @@ -91,8 +195,8 @@ public function render(): View $bundles = PluginBundle::query() ->active() ->with('plugins') - ->when($this->search, function ($query): void { - $query->where(function ($q): void { + ->when($this->search, function (Builder $query): void { + $query->where(function (Builder $q): void { $q->where('name', 'like', "%{$this->search}%") ->orWhere('description', 'like', "%{$this->search}%"); }); @@ -105,6 +209,32 @@ public function render(): View 'plugins' => $plugins, 'bundles' => $bundles, 'authorUser' => $authorUser, + 'typeOptions' => PluginType::cases(), + 'categoryOptions' => PluginCategory::cases(), + 'mobileVersionOptions' => config('plugins.mobile_major_versions', []), + 'categoryUncategorizedValue' => self::CATEGORY_UNCATEGORIZED, + 'mobileVersionUnspecifiedValue' => self::MOBILE_VERSION_UNSPECIFIED, + 'typeLabel' => $this->type !== '' ? PluginType::from($this->type)->label() : null, + 'categoryLabel' => $this->categoryLabel(), + 'mobileVersionLabel' => $this->mobileVersionLabel(), ]); } + + protected function categoryLabel(): ?string + { + return match (true) { + $this->category === '' => null, + $this->category === self::CATEGORY_UNCATEGORIZED => 'Uncategorized', + default => PluginCategory::from($this->category)->label(), + }; + } + + protected function mobileVersionLabel(): ?string + { + return match (true) { + $this->mobileVersion === '' => null, + $this->mobileVersion === self::MOBILE_VERSION_UNSPECIFIED => 'Version unspecified', + default => "v{$this->mobileVersion}.x and up", + }; + } } diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 3a0178f6e..5823e029f 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\PluginActivityType; +use App\Enums\PluginCategory; use App\Enums\PluginStatus; use App\Enums\PluginTier; use App\Enums\PluginType; @@ -899,6 +900,7 @@ protected function casts(): array 'status' => PluginStatus::class, 'type' => PluginType::class, 'tier' => PluginTier::class, + 'category' => PluginCategory::class, 'approved_at' => 'datetime', 'featured' => 'boolean', 'is_active' => 'boolean', diff --git a/app/Services/PluginCategoryClassifier.php b/app/Services/PluginCategoryClassifier.php new file mode 100644 index 000000000..72d24b565 --- /dev/null +++ b/app/Services/PluginCategoryClassifier.php @@ -0,0 +1,40 @@ +name, + $plugin->description, + $plugin->repository_url, + ]))); + + /** @var array> $categoryKeywords */ + $categoryKeywords = config('plugins.category_keywords', []); + + foreach ($categoryKeywords as $categoryValue => $keywords) { + foreach ($keywords as $keyword) { + if (str_contains($haystack, mb_strtolower($keyword))) { + return PluginCategory::from($categoryValue); + } + } + } + + return null; + } +} diff --git a/config/plugins.php b/config/plugins.php new file mode 100644 index 000000000..ba9ffabf6 --- /dev/null +++ b/config/plugins.php @@ -0,0 +1,70 @@ + [4, 3, 2, 1], + + /* + |-------------------------------------------------------------------------- + | Category classification keywords + |-------------------------------------------------------------------------- + | + | Keywords matched case-insensitively against a plugin's name, description, + | and repository URL to guess its category for `plugins:backfill-categories`. + | Checked in the order listed below; the first category with a matching + | keyword wins. + | + */ + + 'category_keywords' => [ + PluginCategory::Media->value => [ + 'camera', 'photo', 'image', 'gallery', 'video', 'audio', 'music', + 'player', 'media', 'barcode', 'qr code', 'scanner', 'speech', + 'text-to-speech', 'text to speech', + ], + PluginCategory::Security->value => [ + 'biometric', 'face id', 'touch id', 'fingerprint', 'authentication', + 'secure', 'security', 'encryption', 'keychain', 'password', + 'local-auth', 'local auth', + ], + PluginCategory::Connectivity->value => [ + 'bluetooth', 'nfc', 'wifi', 'wi-fi', 'websocket', 'network', + 'connectivity', 'beacon', 'deep link', 'deep-link', + ], + PluginCategory::Notifications->value => [ + 'push notification', 'push-notification', 'notification', 'alert', + 'reminder', 'fcm', 'apns', + ], + PluginCategory::Payments->value => [ + 'payment', 'stripe', 'in-app purchase', 'in app purchase', 'iap', + 'billing', 'checkout', 'subscription', 'paypal', 'wallet', + 'admob', 'advertis', + ], + PluginCategory::Analytics->value => [ + 'analytics', 'crashlytics', 'sentry', 'firebase', 'tracking', + 'metrics', 'telemetry', + ], + PluginCategory::System->value => [ + 'device info', 'battery', 'sensor', 'haptic', 'clipboard', + 'keyboard', 'orientation', 'brightness', 'status bar', + 'status-bar', 'splash screen', 'splash-screen', 'app icon', + 'app-icon', 'widget', 'background task', 'background-task', + 'offline sync', 'offline-sync', 'file picker', 'file-picker', + 'contacts', 'calendar', 'geolocation', 'maps', 'health', + ], + ], + +]; diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php index 4bfeeac1c..a4907aa08 100644 --- a/database/factories/PluginFactory.php +++ b/database/factories/PluginFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\PluginCategory; use App\Enums\PluginStatus; use App\Enums\PluginType; use App\Models\Plugin; @@ -111,6 +112,7 @@ public function definition(): array 'ios_version' => fake()->randomElement(['15.0+', '16.0+', '14.0+', '17.0+', null]), 'android_version' => fake()->randomElement(['12+', '13+', '11+', '14+', null]), 'type' => PluginType::Free, + 'category' => null, 'status' => PluginStatus::Pending, 'featured' => false, 'rejection_reason' => null, @@ -204,6 +206,13 @@ public function paid(): static ]); } + public function category(PluginCategory $category): static + { + return $this->state(fn (array $attributes) => [ + 'category' => $category, + ]); + } + public function withoutDescription(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_09_05_000000_add_category_to_plugins_table.php b/database/migrations/2026_09_05_000000_add_category_to_plugins_table.php new file mode 100644 index 000000000..006870990 --- /dev/null +++ b/database/migrations/2026_09_05_000000_add_category_to_plugins_table.php @@ -0,0 +1,28 @@ +string('category')->nullable()->after('tier'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('category'); + }); + } +}; diff --git a/resources/views/livewire/plugin-directory.blade.php b/resources/views/livewire/plugin-directory.blade.php index f86769f02..72b9fbd83 100644 --- a/resources/views/livewire/plugin-directory.blade.php +++ b/resources/views/livewire/plugin-directory.blade.php @@ -64,8 +64,45 @@ class="absolute inset-y-0 right-0 flex items-center pr-4 text-gray-400 hover:tex + {{-- Filters --}} + @if ($view === 'plugins') +
+ + + + + +
+ @endif + {{-- Active Filters --}} - @if ($search || $authorUser) + @if ($search || $authorUser || ($view === 'plugins' && ($type || $category || $mobileVersion)))
@if ($authorUser) @@ -101,6 +138,48 @@ class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600" @endif + @if ($view === 'plugins' && $type) + + {{ $typeLabel }} + + + @endif + @if ($view === 'plugins' && $category) + + {{ $categoryLabel }} + + + @endif + @if ($view === 'plugins' && $mobileVersion) + + {{ $mobileVersionLabel }} + + + @endif {{ $plugins->total() }} {{ Str::plural('result', $plugins->total()) }} @@ -161,14 +240,19 @@ class="mt-4 inline-flex items-center gap-2 rounded-lg bg-gray-200 px-4 py-2 text

No plugins found

- @if ($search || $authorUser) + @php + $hasFilters = $type || $category || $mobileVersion; + @endphp + @if ($search || $authorUser || $hasFilters)

@if ($authorUser && $search) No plugins by {{ $authorUser->display_name }} match your search. @elseif ($authorUser) {{ $authorUser->display_name }} hasn't published any plugins yet. - @else + @elseif ($search) No plugins match your search. Try a different term. + @else + No plugins match the selected filters. Try different options. @endif

@@ -181,6 +265,15 @@ class="inline-flex items-center gap-2 rounded-lg bg-gray-200 px-4 py-2 text-sm f Clear search @endif + @if ($hasFilters) + + @endif @if ($authorUser)