Skip to content
This repository was archived by the owner on Nov 20, 2025. It is now read-only.

Commit 9550a22

Browse files
authored
Merge pull request #314 from crowdfavorite/feat/plugin_search
2 parents 9a3b16e + 0d11459 commit 9550a22

File tree

11 files changed

+271
-216
lines changed

11 files changed

+271
-216
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,8 @@ phpstan.neon
2727
# helper files would not normally be ignored, but they're currently broken, so it's a local decision to use them
2828
_ide_helper.php
2929
.phpstorm.meta.php
30+
docker-compose.override.yml
3031

32+
/docker/traefik/.env.example
33+
/docker/pgadmin
34+
/docker/traefik/certs

app/Http/Controllers/API/WpOrg/Plugins/PluginInformation_1_0_Controller.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
use App\Http\Controllers\Controller;
66
use App\Models\WpOrg\ClosedPlugin;
7-
use App\Services\Plugins\PluginHotTagsService;
8-
use App\Services\Plugins\PluginInformationService;
9-
use App\Services\Plugins\QueryPluginsService;
7+
use App\Services\PluginServices\PluginHotTagsService;
8+
use App\Services\PluginServices\PluginInformationService;
9+
use App\Services\PluginServices\QueryPluginsService;
1010
use App\Values\WpOrg\Plugins\ClosedPluginResponse;
1111
use App\Values\WpOrg\Plugins\PluginInformationRequest;
1212
use App\Values\WpOrg\Plugins\PluginResponse;

app/Http/Controllers/API/WpOrg/Plugins/PluginInformation_1_2_Controller.php

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,61 +4,79 @@
44

55
use App\Http\Controllers\Controller;
66
use App\Models\WpOrg\ClosedPlugin;
7-
use App\Services\Plugins\PluginHotTagsService;
8-
use App\Services\Plugins\PluginInformationService;
9-
use App\Services\Plugins\QueryPluginsService;
10-
use App\Values\WpOrg\Plugins\ClosedPluginResponse;
11-
use App\Values\WpOrg\Plugins\PluginInformationRequest;
12-
use App\Values\WpOrg\Plugins\PluginResponse;
13-
use App\Values\WpOrg\Plugins\QueryPluginsRequest;
7+
use App\Services\PluginServices;
8+
use App\Values\WpOrg\Plugins;
149
use Illuminate\Http\JsonResponse;
1510
use Illuminate\Http\Request;
1611

1712
class PluginInformation_1_2_Controller extends Controller
1813
{
1914
public function __construct(
20-
private readonly PluginInformationService $pluginInfo,
21-
private readonly QueryPluginsService $queryPlugins,
22-
private readonly PluginHotTagsService $hotTags,
23-
) {}
15+
private readonly PluginServices\PluginInformationService $pluginInformationService,
16+
private readonly PluginServices\QueryPluginsService $queryPluginsService,
17+
private readonly PluginServices\PluginHotTagsService $hotTagsService,
18+
)
19+
{
20+
}
2421

2522
public function __invoke(Request $request): JsonResponse
2623
{
27-
return match ($request->query('action')) {
28-
'query_plugins' => $this->queryPlugins(QueryPluginsRequest::from($request)),
29-
'plugin_information' => $this->pluginInformation(PluginInformationRequest::from($request)),
30-
'hot_tags', 'popular_tags' => $this->hotTags($request),
31-
default => response()->json(['error' => 'Invalid action'], 400),
32-
};
24+
$action = $request->query('action', '');
25+
26+
$handlers = [
27+
'query_plugins' => fn() => $this->queryPlugins(Plugins\QueryPluginsRequest::from($request)),
28+
'plugin_information' => fn() => $this->pluginInformation(Plugins\PluginInformationRequest::from($request)),
29+
'hot_tags' => fn() => $this->hotTags($request),
30+
'popular_tags' => fn() => $this->hotTags($request),
31+
];
32+
33+
if (!isset($handlers[$action])) {
34+
return response()->json(['error' => 'Invalid action'], 400);
35+
}
36+
37+
return $handlers[$action]();
3338
}
3439

35-
private function pluginInformation(PluginInformationRequest $req): JsonResponse
40+
/**
41+
* @param Plugins\PluginInformationRequest $req
42+
* @return JsonResponse
43+
*/
44+
private function pluginInformation(Plugins\PluginInformationRequest $req): JsonResponse
3645
{
37-
$plugin = $this->pluginInfo->findBySlug($req->slug);
46+
$plugin = $this->pluginInformationService->findBySlug($req->slug);
3847

3948
if (!$plugin) {
4049
return response()->json(['error' => 'Plugin not found'], 404);
4150
}
4251

52+
$resource = Plugins\PluginResponse::from($plugin);
53+
$status = 200;
54+
4355
if ($plugin instanceof ClosedPlugin) {
44-
$resource = ClosedPluginResponse::from($plugin);
56+
$resource = Plugins\ClosedPluginResponse::from($plugin);
4557
$status = 404;
46-
} else {
47-
$resource = PluginResponse::from($plugin);
48-
$status = 200;
4958
}
59+
5060
return response()->json($resource, $status);
5161
}
5262

53-
private function queryPlugins(QueryPluginsRequest $request): JsonResponse
63+
/**
64+
* @param Plugins\QueryPluginsRequest $request
65+
* @return JsonResponse
66+
*/
67+
private function queryPlugins(Plugins\QueryPluginsRequest $request): JsonResponse
5468
{
55-
$result = $this->queryPlugins->queryPlugins($request);
69+
$result = $this->queryPluginsService->queryPlugins($request);
5670
return response()->json($result);
5771
}
5872

73+
/**
74+
* @param Request $request
75+
* @return JsonResponse
76+
*/
5977
private function hotTags(Request $request): JsonResponse
6078
{
61-
$tags = $this->hotTags->getHotTags((int) $request->query('number', '-1'));
79+
$tags = $this->hotTagsService->getHotTags((int)$request->query('number', '-1'));
6280
return response()->json($tags);
6381
}
6482
}

app/Http/Controllers/API/WpOrg/Plugins/PluginUpdateCheck_1_1_Controller.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace App\Http\Controllers\API\WpOrg\Plugins;
44

55
use App\Http\Controllers\Controller;
6-
use App\Services\Plugins\PluginUpdateService;
6+
use App\Services\PluginServices\PluginUpdateService;
77
use App\Values\WpOrg\Plugins\PluginUpdateCheckRequest;
88
use Illuminate\Http\JsonResponse;
99
use Illuminate\Http\Request;

app/Services/Plugins/PluginHotTagsService.php renamed to app/Services/PluginServices/PluginHotTagsService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
namespace App\Services\Plugins;
3+
namespace App\Services\PluginServices;
44

55
use App\Models\WpOrg\PluginTag;
66
use App\Values\WpOrg\Plugins\PluginHotTagsResponse;

app/Services/Plugins/PluginInformationService.php renamed to app/Services/PluginServices/PluginInformationService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
namespace App\Services\Plugins;
3+
namespace App\Services\PluginServices;
44

55
use App\Models\WpOrg\ClosedPlugin;
66
use App\Models\WpOrg\Plugin;

app/Services/Plugins/PluginUpdateService.php renamed to app/Services/PluginServices/PluginUpdateService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
namespace App\Services\Plugins;
3+
namespace App\Services\PluginServices;
44

55
use App\Models\WpOrg\Plugin;
66
use App\Values\WpOrg\Plugins\PluginUpdateCheckRequest;
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
namespace App\Services\PluginServices;
4+
5+
use App\Models\WpOrg\Plugin;
6+
use App\Utils\Regex;
7+
use App\Values\WpOrg\Plugins;
8+
use Illuminate\Database\Eloquent\Builder;
9+
10+
class QueryPluginsService
11+
{
12+
public function queryPlugins(Plugins\QueryPluginsRequest $req): Plugins\QueryPluginsResponse
13+
{
14+
$page = $req->page;
15+
$perPage = $req->per_page;
16+
$browse = $req->browse ?: 'popular';
17+
$search = $req->search ?? null;
18+
$author = $req->author ?? null;
19+
20+
// Operators coming from the DTO
21+
$tags = $req->tags ?? [];
22+
$tagAnd = $req->tagAnd ?? [];
23+
$tagOr = $req->tagOr ?? [];
24+
$tagNot = $req->tagNot ?? [];
25+
26+
// merge base tags with tagOr
27+
$anyTags = array_values(array_unique([...$tags, ...$tagOr]));
28+
29+
// Ad hoc pipeline because Laravel's Pipeline class is awful
30+
$callbacks = collect();
31+
32+
!empty($anyTags) && $callbacks->push(fn($q) => self::applyTagAny($q, $anyTags));
33+
!empty($tagAnd) && $callbacks->push(fn($q) => self::applyTagAll($q, $tagAnd));
34+
!empty($tagNot) && $callbacks->push(fn($q) => self::applyTagNot($q, $tagNot));
35+
36+
$search && $callbacks->push(fn($q) => self::applySearchWeighted($q, $search, $req));
37+
$author && $callbacks->push(fn($q) => self::applyAuthor($q, $author));
38+
!$search && $callbacks->push(fn($q) => self::applyBrowse($q, $browse));
39+
/** @var Builder<Plugin> $query */
40+
$query = $callbacks->reduce(fn($query, $callback) => $callback($query), Plugin::query());
41+
42+
$total = $query->count();
43+
$totalPages = (int)ceil($total / $perPage);
44+
45+
$plugins = $query
46+
->with('contributors')
47+
->offset(($page - 1) * $perPage)
48+
->limit($perPage)
49+
->get()
50+
->unique('slug')
51+
->map(fn($plugin) => Plugins\PluginResponse::from($plugin));
52+
53+
return Plugins\QueryPluginsResponse::from([
54+
'plugins' => $plugins,
55+
'info' => ['page' => $page, 'pages' => $totalPages, 'results' => $total],
56+
]);
57+
}
58+
59+
/**
60+
* Apply weighted search with proper scoring for each union clause
61+
*
62+
* @param Builder<Plugin> $query
63+
* @return Builder<Plugin> Returns a new query with weighted search applied
64+
*/
65+
public static function applySearchWeighted(
66+
Builder $query,
67+
string $search,
68+
Plugins\QueryPluginsRequest $request
69+
): Builder
70+
{
71+
$lcsearch = mb_strtolower($search);
72+
$slug = Regex::replace('/[^-\w]+/', '-', $lcsearch);
73+
$wordchars = Regex::replace('/\W+/', '', $lcsearch);
74+
$sortColumn = self::browseToSortColumn($request->browse);
75+
76+
return $query
77+
->where(fn($q) => $q
78+
->where('slug', $search)
79+
->orWhere('name', 'like', "$search%")
80+
->orWhereRaw("slug %> ?", [$wordchars])
81+
->orWhereRaw("name %> ?", [$wordchars])
82+
->orWhereRaw("short_description %> ?", [$wordchars])
83+
->orWhereFullText('description', $search)
84+
)
85+
->selectRaw("plugins.*,
86+
CASE
87+
WHEN slug = ? THEN 1000000
88+
WHEN name = ? THEN 900000
89+
WHEN slug LIKE ? THEN 800000
90+
WHEN name LIKE ? THEN 700000
91+
WHEN slug %> ? THEN 600000
92+
WHEN name %> ? THEN 500000
93+
WHEN short_description %> ? THEN 400000
94+
WHEN to_tsvector('english', description) @@ plainto_tsquery(?) THEN 300000
95+
ELSE 0
96+
END + log(GREATEST($sortColumn, 1)) AS score", [
97+
$search,
98+
$search,
99+
"$slug%",
100+
"$search%",
101+
$wordchars,
102+
$wordchars,
103+
$wordchars,
104+
$search,
105+
])
106+
->orderByDesc('score');
107+
}
108+
109+
/** @param Builder<Plugin> $query */
110+
public static function applyAuthor(Builder $query, string $author): Builder
111+
{
112+
return $query->where(fn(Builder $q)
113+
=> $q
114+
->whereRaw("author %> '$author'")
115+
->orWhereHas(
116+
'contributors',
117+
fn(Builder $q)
118+
=> $q
119+
->whereRaw("user_nicename %> '$author'")
120+
->orWhereRaw("display_name %> '$author'"),
121+
));
122+
}
123+
124+
/** @param Builder<Plugin> $query */
125+
public static function applyTagAny(Builder $query, array $tags): Builder
126+
{
127+
return $query->whereHas('tags', fn(Builder $q) => $q->whereIn('slug', $tags));
128+
}
129+
130+
/** @param Builder<Plugin> $query */
131+
public static function applyTagAll(Builder $query, array $tags): Builder
132+
{
133+
return $query->whereHas(
134+
'tags',
135+
fn(Builder $q) => $q->whereIn('slug', $tags),
136+
'>=',
137+
count($tags)
138+
);
139+
}
140+
141+
/** @param Builder<Plugin> $query */
142+
public static function applyTagNot(Builder $query, array $slugs): Builder
143+
{
144+
return $query->whereDoesntHave('tags', fn(Builder $q) => $q->whereIn('slug', $slugs));
145+
}
146+
147+
/**
148+
* Apply sorting based on browse parameter
149+
*
150+
* @param Builder<Plugin> $query
151+
*/
152+
public static function applyBrowse(Builder $query, string $browse): Builder
153+
{
154+
return $query->reorder(self::browseToSortColumn($browse), 'desc');
155+
}
156+
157+
public static function browseToSortColumn(?string $browse): string
158+
{
159+
return match ($browse) {
160+
'new' => 'added',
161+
'updated' => 'last_updated',
162+
'top-rated', 'featured' => 'rating',
163+
default => 'active_installs',
164+
};
165+
}
166+
}

0 commit comments

Comments
 (0)