Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions app/Console/Commands/BackfillPluginCategories.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Enums\PluginCategory;
use App\Models\Plugin;
use App\Services\PluginCategoryClassifier;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;

final class BackfillPluginCategories extends Command
{
protected $signature = 'plugins:backfill-categories
{--dry-run : Preview proposed categories without saving them}';

protected $description = 'Assign a category to already-published plugins that have none, using a keyword-based heuristic';

public function handle(PluginCategoryClassifier $classifier): int
{
$dryRun = (bool) $this->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<int, array{0: Plugin, 1: PluginCategory}> $matched */
$matched = new Collection;
/** @var Collection<int, Plugin> $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;
}
}
27 changes: 27 additions & 0 deletions app/Enums/PluginCategory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Enums;

enum PluginCategory: string
{
case Media = 'media';
case Security = 'security';
case Connectivity = 'connectivity';
case Notifications = 'notifications';
case Payments = 'payments';
case Analytics = 'analytics';
case System = 'system';

public function label(): string
{
return match ($this) {
self::Media => 'Media',
self::Security => 'Security',
self::Connectivity => 'Connectivity',
self::Notifications => 'Notifications',
self::Payments => 'Payments',
self::Analytics => 'Analytics',
self::System => 'System',
};
}
}
140 changes: 135 additions & 5 deletions app/Livewire/PluginDirectory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = '';

Expand All @@ -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();
Expand All @@ -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 = '';
Expand All @@ -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';
Expand All @@ -75,24 +155,48 @@ 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);

$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}%");
});
Expand All @@ -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",
};
}
}
2 changes: 2 additions & 0 deletions app/Models/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
40 changes: 40 additions & 0 deletions app/Services/PluginCategoryClassifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Services;

use App\Enums\PluginCategory;
use App\Models\Plugin;

final class PluginCategoryClassifier
{
/**
* Guess the best-fit category for a plugin from its name, description,
* and repository URL, using the keyword map in `config('plugins.category_keywords')`.
*
* Returns null rather than guessing when nothing matches confidently, so
* callers can leave the plugin uncategorized for a maintainer to review.
*/
public function classify(Plugin $plugin): ?PluginCategory
{
$haystack = mb_strtolower(implode(' ', array_filter([
$plugin->name,
$plugin->description,
$plugin->repository_url,
])));

/** @var array<string, array<int, string>> $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;
}
}
Loading