update plugin_information, implement hot_tags for /plugins/info (#87)

* fix: mv grumphp.yml grumphp.yml.dist and use explicit phpcsfixer config

* refactor: move NotFoundException to App\Exceptions

* refactor: move plugin services to App\Services\Plugins

* feat: implement hot_tags action for /plugins/info endpoint

* fix: make plugin_information fields match upstream (order still differs)

* fix: give nullable columns in Plugin nullable types

* fix(tests): rm 'downloaded' key from assertWpPluginAPIStructure

* refactor: break out QueryPluginsService from PluginInformationService

* refactor: break queryPlugins into apply* methods

* fix: change orderBy to reorder in applyBrowse()

* feat: add slug to plugin search criteria

* fix: variable naming in Plugin::fillFromMetadata

* fix: use tags table for tag query

* feat: add PluginTagFactory (not used yet)

* feat: implement /core/importers

* fix: tags fields removed and refactor plugin tests (#89)

- On #87, a many-to-many relationship for tags was introduced; keeping the tags field on the Plugin table is unnecessary
- Refactor the Plugin tests to use the new many-to-many relationshipt
- Refactor the PluginFactory to allow create plugins with the tags relationship

* style: make fix

* style: take out some redundant ::query() calls

* fix: add cascade delete to tag join tables

---------

Co-authored-by: Enrique Chavez <noone@tmeister.net>
This commit is contained in:
Chuck Adams 2024-11-02 17:42:15 -06:00 committed by GitHub
parent b2d0a89838
commit 123803ec83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 532 additions and 195 deletions

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ yarn-error.log
# helper files would not normally be ignored, but they're currently broken, so it's a local decision to use them
_ide_helper.php
.phpstorm.meta.php

grumphp.yml
π

View file

@ -0,0 +1,31 @@
<?php

namespace App\Data\WpOrg\Plugins;

use Illuminate\Support\Collection;
use Spatie\LaravelData\Data;

class PluginHotTagsResponse extends Data
{
public function __construct(
public string $slug,
public string $name,
public int $count,
) {}

/**
* Static method to create an instance from a Plugin model.
* @param Collection<int,covariant array{
* slug: string,
* name: string,
* count: int,
* }> $pluginTags
* @return Collection<string, covariant PluginHotTagsResponse>
*/
public static function fromCollection(Collection $pluginTags): Collection
{
return $pluginTags->mapWithKeys(fn($plugin) => [
$plugin['slug'] => self::from($plugin),
]);
}
}

View file

@ -5,7 +5,7 @@ namespace App\Data\WpOrg\Themes;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Data;

class HotTagsResponse extends Data
class ThemeHotTagsResponse extends Data
{
public function __construct(
public string $slug,
@ -20,7 +20,7 @@ class HotTagsResponse extends Data
* name: string,
* count: int,
* }> $themeTags
* @return Collection<string, covariant HotTagsResponse>
* @return Collection<string, covariant ThemeHotTagsResponse>
*/
public static function fromCollection(Collection $themeTags): Collection
{

View file

@ -1,6 +1,6 @@
<?php

namespace App;
namespace App\Exceptions;

use RuntimeException;

View file

@ -0,0 +1,68 @@
<?php

namespace App\Http\Controllers\API\WpOrg\Core;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

class ImportersController extends Controller
{
public function __invoke(string $version): JsonResponse|Response
{
$response = [
"importers" => [
"blogger" => [
"name" => "Blogger",
"description" => "Install the Blogger importer to import posts, comments, and users from a Blogger blog.",
"plugin-slug" => "blogger-importer",
"importer-id" => "blogger",
],
"wpcat2tag" => [
"name" => "Categories and Tags Converter",
"description" => "Install the category/tag converter to convert existing categories to tags or tags to categories, selectively.",
"plugin-slug" => "wpcat2tag-importer",
"importer-id" => "wpcat2tag",
],
"livejournal" => [
"name" => "LiveJournal",
"description" => "Install the LiveJournal importer to import posts from LiveJournal using their API.",
"plugin-slug" => "livejournal-importer",
"importer-id" => "livejournal",
],
"movabletype" => [
"name" => "Movable Type and TypePad",
"description" => "Install the Movable Type importer to import posts and comments from a Movable Type or TypePad blog.",
"plugin-slug" => "movabletype-importer",
"importer-id" => "mt",
],
"opml" => [
"name" => "Blogroll",
"description" => "Install the blogroll importer to import links in OPML format.",
"plugin-slug" => "opml-importer",
"importer-id" => "opml",
],
"rss" => [
"name" => "RSS",
"description" => "Install the RSS importer to import posts from an RSS feed.",
"plugin-slug" => "rss-importer",
"importer-id" => "rss",
],
"tumblr" => [
"name" => "Tumblr",
"description" => "Install the Tumblr importer to import posts &amp; media from Tumblr using their API.",
"plugin-slug" => "tumblr-importer",
"importer-id" => "tumblr",
],
"wordpress" => [
"name" => "WordPress",
"description" => "Install the WordPress importer to import posts, pages, comments, custom fields, categories, and tags from a WordPress export file.",
"plugin-slug" => "wordpress-importer",
"importer-id" => "wordpress",
],
],
"translated" => false,
];
return $version === '1.0' ? new Response(serialize((object) $response)) : response()->json($response);
}
}

View file

@ -7,14 +7,18 @@ use App\Http\Requests\Plugins\PluginInformationRequest;
use App\Http\Requests\Plugins\QueryPluginsRequest;
use App\Http\Resources\Plugins\PluginCollection;
use App\Http\Resources\Plugins\PluginResource;
use App\Services\PluginInformationService;
use App\Services\Plugins\PluginHotTagsService;
use App\Services\Plugins\PluginInformationService;
use App\Services\Plugins\QueryPluginsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class PluginInformation_1_2_Controller extends Controller
{
public function __construct(
private readonly PluginInformationService $pluginService,
private readonly PluginInformationService $pluginInfo,
private readonly QueryPluginsService $queryPlugins,
private readonly PluginHotTagsService $hotTags,
) {}

public function __invoke(Request $request): JsonResponse
@ -22,6 +26,7 @@ class PluginInformation_1_2_Controller extends Controller
return match ($request->query('action')) {
'query_plugins' => $this->queryPlugins(new QueryPluginsRequest($request->all())),
'plugin_information' => $this->pluginInformation(new PluginInformationRequest($request->all())),
'hot_tags', 'popular_tags' => $this->hotTags($request),
default => response()->json(['error' => 'Invalid action'], 400),
};
}
@ -33,7 +38,7 @@ class PluginInformation_1_2_Controller extends Controller
return response()->json(['error' => 'Slug is required'], 400);
}

$plugin = $this->pluginService->findBySlug($request->getSlug());
$plugin = $this->pluginInfo->findBySlug($request->getSlug());

if (!$plugin) {
return response()->json(['error' => 'Plugin not found'], 404);
@ -44,7 +49,7 @@ class PluginInformation_1_2_Controller extends Controller

private function queryPlugins(QueryPluginsRequest $request): JsonResponse
{
$result = $this->pluginService->queryPlugins(
$result = $this->queryPlugins->queryPlugins(
page: $request->getPage(),
perPage: $request->getPerPage(),
search: $request->query('search'),
@ -60,4 +65,10 @@ class PluginInformation_1_2_Controller extends Controller
$result['total']
));
}

private function hotTags(Request $request): JsonResponse
{
$tags = $this->hotTags->getHotTags((int) $request->query('number', '-1'));
return response()->json($tags);
}
}

View file

@ -5,7 +5,7 @@ namespace App\Http\Controllers\API\WpOrg\Plugins;
use App\Http\Controllers\Controller;
use App\Http\Requests\Plugins\PluginUpdateRequest;
use App\Http\Resources\Plugins\PluginUpdateCollection;
use App\Services\PluginUpdateService;
use App\Services\Plugins\PluginUpdateService;
use Illuminate\Http\JsonResponse;

use function Safe\json_decode;

View file

@ -4,13 +4,13 @@ namespace App\Http\Controllers\API\WpOrg\Themes;

use App\Data\WpOrg\Themes\QueryThemesRequest;
use App\Data\WpOrg\Themes\ThemeInformationRequest;
use App\Exceptions\NotFoundException;
use App\Http\Controllers\Controller;
use App\Http\Resources\ThemeCollection;
use App\Http\Resources\ThemeResource;
use App\NotFoundException;
use App\Services\Themes\FeatureListService;
use App\Services\Themes\HotTagsService;
use App\Services\Themes\QueryThemesService;
use App\Services\Themes\ThemeHotTagsService;
use App\Services\Themes\ThemeInformationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -24,7 +24,7 @@ class ThemeController extends Controller
public function __construct(
private readonly QueryThemesService $queryThemes,
private readonly ThemeInformationService $themeInfo,
private readonly HotTagsService $hotTags,
private readonly ThemeHotTagsService $hotTags,
private readonly FeatureListService $featureList,
) {}


View file

@ -2,6 +2,7 @@

namespace App\Http\Resources\Plugins;

use App\Models\WpOrg\Plugin;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;

@ -14,14 +15,17 @@ abstract class BasePluginResource extends JsonResource
*/
protected function getCommonAttributes(): array
{
$plugin = $this->resource;
assert($plugin instanceof Plugin);

return [
'name' => $this->resource->name,
'slug' => $this->resource->slug,
'version' => $this->resource->version,
'requires' => $this->resource->requires,
'tested' => $this->resource->tested,
'requires_php' => $this->resource->requires_php,
'download_link' => $this->resource->download_link,
'name' => $plugin->name,
'slug' => $plugin->slug,
'version' => $plugin->version,
'requires' => $plugin->requires,
'tested' => $plugin->tested,
'requires_php' => $plugin->requires_php,
'download_link' => $plugin->download_link,
];
}


View file

@ -2,10 +2,13 @@

namespace App\Http\Resources\Plugins;

use App\Models\WpOrg\Plugin;
use Illuminate\Http\Request;

class PluginResource extends BasePluginResource
{
public const LAST_UPDATED_DATE_FORMAT = 'Y-m-d H:ia T'; // .org's goofy format: "2024-09-27 9:53pm GMT"

/**
* Transform the resource into an array.
*
@ -13,35 +16,45 @@ class PluginResource extends BasePluginResource
*/
public function toArray(Request $request): array
{
$plugin = $this->resource;
assert($plugin instanceof Plugin);

$data = array_merge($this->getCommonAttributes(), [
'author' => $this->resource->author,
'author_profile' => $this->resource->author_profile,
'rating' => $this->resource->rating,
'num_ratings' => $this->resource->num_ratings,
'ratings' => $this->mapRatings($this->resource->ratings),
'support_threads' => $this->resource->support_threads,
'support_threads_resolved' => $this->resource->support_threads_resolved,
'active_installs' => $this->resource->active_installs,
'downloaded' => $this->resource->downloaded,
'last_updated' => $this->resource->last_updated?->format('Y-m-d H:i:s'),
'added' => $this->resource->added?->format('Y-m-d'),
'homepage' => $this->resource->homepage,
'tags' => $this->resource->tags,
'donate_link' => $this->resource->donate_link,
'author' => $plugin->author,
'author_profile' => $plugin->author_profile,
'rating' => $plugin->rating,
'num_ratings' => $plugin->num_ratings,
'ratings' => $this->mapRatings($plugin->ratings),
'support_threads' => $plugin->support_threads,
'support_threads_resolved' => $plugin->support_threads_resolved,
'active_installs' => $plugin->active_installs,
'last_updated' => $plugin->last_updated?->format(self::LAST_UPDATED_DATE_FORMAT),
'added' => $plugin->added->format('Y-m-d'),
'homepage' => $plugin->homepage,
'tags' => $plugin->tags,
'donate_link' => $plugin->donate_link,
'requires_plugins' => $plugin->requires_plugins ?? [],
]);

return match ($request->query('action')) {
'query_plugins' => array_merge($data, [
'short_description' => $this->resource->short_description,
'description' => $this->resource->description,
'icons' => $this->resource->icons,
'requires_plugins' => $this->resource->requires_plugins ?? [],
'downloaded' => $plugin->downloaded,
'short_description' => $plugin->short_description,
'description' => $plugin->description,
'icons' => $plugin->icons,
]),
'plugin_information' => array_merge($data, [
'sections' => $this->resource->sections,
'versions' => $this->resource->versions,
'contributors' => $this->resource->contributors,
'screenshots' => $this->resource->screenshots,
'sections' => $plugin->sections,
'versions' => $plugin->versions,
'contributors' => $plugin->contributors,
'screenshots' => $plugin->screenshots,
'support_url' => $plugin->support_url,
'upgrade_notice' => $plugin->upgrade_notice,
'business_model' => $plugin->business_model,
'repository_url' => $plugin->repository_url,
'commercial_support_url' => $plugin->commercial_support_url,
'banners' => $plugin->banners,
'preview_link' => $plugin->preview_link,
]),
default => $data,
};

View file

@ -15,7 +15,7 @@ use Illuminate\Support\Facades\DB;
use InvalidArgumentException;

/**
* @property string $id
* @property-read string $id
* @property string $slug
* @property string $name
* @property string $short_description
@ -23,37 +23,37 @@ use InvalidArgumentException;
* @property string $version
* @property string $author
* @property string $requires
* @property string $requires_php
* @property string|null $requires_php
* @property string $tested
* @property string $download_link
* @property CarbonImmutable $added
* @property CarbonImmutable $last_updated
* @property string $author_profile
* @property CarbonImmutable|null $last_updated
* @property string|null $author_profile
* @property int $rating
* @property array $ratings
* @property array|null $ratings
* @property int $num_ratings
* @property int $support_threads
* @property int $support_threads_resolved
* @property int $active_installs
* @property int $downloaded
* @property string $homepage
* @property array $banners
* @property array $tags
* @property string $donate_link
* @property array $contributors
* @property array $icons
* @property array $source
* @property string $business_model
* @property string $commercial_support_url
* @property string $support_url
* @property string $preview_link
* @property string $repository_url
* @property array $requires_plugins
* @property array $compatibility
* @property array $screenshots
* @property array $sections
* @property array $versions
* @property array $upgrade_notice
* @property string|null $homepage
* @property array|null $banners
* @property string|null $donate_link
* @property array|null $contributors
* @property array|null $icons
* @property array|null $source
* @property string|null $business_model
* @property string|null $commercial_support_url
* @property string|null $support_url
* @property string|null $preview_link
* @property string|null $repository_url
* @property array|null $requires_plugins
* @property array|null $compatibility
* @property array|null $screenshots
* @property array|null $sections
* @property array|null $versions
* @property array|null $upgrade_notice
* @property array<string, string> $tags
*/
final class Plugin extends BaseModel
{
@ -66,6 +66,9 @@ final class Plugin extends BaseModel

protected $table = 'plugins';

/** @phpstan-ignore-next-line */
protected $appends = ['tags'];

protected function casts(): array
{
return [
@ -129,7 +132,7 @@ final class Plugin extends BaseModel

public static function getOrCreateFromSyncPlugin(SyncPlugin $syncPlugin): self
{
return self::where('sync_id', $syncPlugin->id)->first() ?? self::createFromSyncPlugin($syncPlugin);
return self::query()->firstWhere('sync_id', $syncPlugin->id) ?? self::createFromSyncPlugin($syncPlugin);
}

public static function createFromSyncPlugin(SyncPlugin $syncPlugin): self
@ -176,10 +179,11 @@ final class Plugin extends BaseModel
$pluginTags = [];
$this->tags()->detach();
foreach ($data['tags'] as $tagSlug => $name) {
$themeTags[] = PluginTag::firstOrCreate(['slug' => $tagSlug], ['slug' => $tagSlug, 'name' => $name]);
$pluginTags[] = PluginTag::firstOrCreate(['slug' => $tagSlug], ['slug' => $tagSlug, 'name' => $name]);
}
$this->tags()->saveMany($pluginTags);
}

return $this->fill([
'name' => $data['name'],
'short_description' => self::truncate($data['short_description'] ?? '', 149),
@ -202,7 +206,7 @@ final class Plugin extends BaseModel
'downloaded' => $data['downloaded'] ?? '',
'homepage' => $data['homepage'] ?? null,
'banners' => $data['banners'] ?? null,
'tags' => $data['tags'] ?? null,
// 'tags' => $data['tags'] ?? null,
'donate_link' => self::truncate($data['donate_link'] ?? null, 1024),
'contributors' => $data['contributors'] ?? null,
'icons' => $data['icons'] ?? null,
@ -232,4 +236,17 @@ final class Plugin extends BaseModel
{
return $str === null ? $str : mb_substr($str, 0, $len, 'utf8');
}

/**
* Get the tags attribute.
*
* @return array<string, string>
*/
public function getTagsAttribute(): array
{
return $this->tags()
->get()
->pluck('name', 'slug')
->toArray();
}
}

View file

@ -3,13 +3,17 @@
namespace App\Models\WpOrg;

use App\Models\BaseModel;
use Database\Factories\WpOrg\PluginTagFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

/**
* @property string $id
* @property string $plugi
* @property-read string $id
* @property string $slug
* @property string $name
* @property Collection<Plugin> $plugins
*/
final class PluginTag extends BaseModel
{
@ -17,6 +21,9 @@ final class PluginTag extends BaseModel

use HasUuids;

/** @use HasFactory<PluginTagFactory> */
use HasFactory;

protected $table = 'plugin_tags';

protected function casts(): array
@ -39,7 +46,7 @@ final class PluginTag extends BaseModel
*/
public function plugins(): BelongsToMany
{
return $this->belongsToMany(Plugin::class, 'plugin_plugin_tags', 'tag_id', 'plugin_id');
return $this->belongsToMany(Plugin::class, 'plugin_plugin_tags', 'plugin_tag_id', 'plugin_id');
}

//endregion

View file

@ -1,81 +0,0 @@
<?php

namespace App\Services;

use App\Models\WpOrg\Plugin;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;

class PluginInformationService
{
public function findBySlug(string $slug): ?Plugin
{
return Plugin::query()->where('slug', $slug)->first();
}

/**
* Query plugins with filters and pagination
*
* @return array{
* plugins: Collection<int, Plugin>,
* page: int,
* totalPages: int,
* total: int
* }
*/
public function queryPlugins(
int $page,
int $perPage,
?string $search = null,
?string $tag = null,
?string $author = null,
string $browse = 'popular',
): array {
$query = Plugin::query()
->when($search, function (Builder $query, string $search) {
$query->where(function (Builder $q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('short_description', 'like', "%{$search}%")
->orWhereFullText('description', $search);
});
})
->when($tag, function (Builder $query, string $tag) {
$query->whereJsonContains('tags', $tag);
})
->when($author, function (Builder $query, string $author) {
$query->where('author', 'like', "%{$author}%");
});

$this->applyBrowseSort($query, $browse);

$total = $query->count();
$totalPages = (int) ceil($total / $perPage);

$plugins = $query
->offset(($page - 1) * $perPage)
->limit($perPage)
->get();

return [
'plugins' => $plugins,
'page' => $page,
'totalPages' => $totalPages,
'total' => $total,
];
}

/**
* Apply sorting based on browse parameter
*
* @param Builder<Plugin> $query
*/
private function applyBrowseSort(Builder $query, string $browse): void
{
match ($browse) {
'new' => $query->orderBy('added', 'desc'),
'updated' => $query->orderBy('last_updated', 'desc'),
'top-rated' => $query->orderBy('rating', 'desc'),
default => $query->orderBy('active_installs', 'desc'),
};
}
}

View file

@ -0,0 +1,33 @@
<?php

namespace App\Services\Plugins;

use App\Data\WpOrg\Themes\ThemeHotTagsResponse;
use App\Models\WpOrg\PluginTag;

class PluginHotTagsService
{
/**
* Gets the top tags by plugin count
*
* @return array<string, array{
* name: string,
* slug: string,
* count: int,
* }> */
public function getHotTags(int $count = -1): array
{
$hotTags = PluginTag::withCount('plugins')
->orderBy('plugins_count', 'desc')
->limit($count >= 0 ? $count : 100)
->get(['slug', 'name', 'plugins_count'])
->map(function ($tag) {
return [
'name' => (string) $tag->name,
'slug' => (string) $tag->slug,
'count' => (int) $tag->plugins_count,
];
});
return ThemeHotTagsResponse::fromCollection($hotTags)->toArray();
}
}

View file

@ -0,0 +1,13 @@
<?php

namespace App\Services\Plugins;

use App\Models\WpOrg\Plugin;

class PluginInformationService
{
public function findBySlug(string $slug): ?Plugin
{
return Plugin::query()->where('slug', $slug)->first();
}
}

View file

@ -1,6 +1,6 @@
<?php

namespace App\Services;
namespace App\Services\Plugins;

use App\Models\WpOrg\Plugin;
use Illuminate\Support\Collection;

View file

@ -0,0 +1,89 @@
<?php

namespace App\Services\Plugins;

use App\Models\WpOrg\Plugin;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;

class QueryPluginsService
{
/**
* Query plugins with filters and pagination
*
* @return array{
* plugins: Collection<int, Plugin>,
* page: int,
* totalPages: int,
* total: int
* }
*/
public function queryPlugins(
int $page,
int $perPage,
?string $search = null,
?string $tag = null, // TODO: make this work with more than one tag, the way Themes do
?string $author = null,
string $browse = 'popular',
): array {
$query = Plugin::query()
->when($browse, self::applyBrowse(...))
->when($search, self::applySearch(...))
->when($tag, self::applyTag(...))
->when($author, self::applyAuthor(...));

$total = $query->count();
$totalPages = (int) ceil($total / $perPage);

$plugins = $query
->offset(($page - 1) * $perPage)
->limit($perPage)
->get();

return [
'plugins' => $plugins,
'page' => $page,
'totalPages' => $totalPages,
'total' => $total,
];
}

/** @param Builder<Plugin> $query */
private static function applySearch(Builder $query, string $search): void
{
$query->where(function (Builder $q) use ($search) {
$q->where('slug', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%")
->orWhere('short_description', 'like', "%{$search}%")
->orWhereFullText('description', $search);
});
}

/** @param Builder<Plugin> $query */
private static function applyAuthor(Builder $query, string $author): void
{
$query->whereLike('author', $author);
}

/** @param Builder<Plugin> $query */
private static function applyTag(Builder $query, string $tag): void
{
$query->whereHas('tags', fn(Builder $q) => $q->whereIn('slug', [$tag]));
}

/**
* Apply sorting based on browse parameter
*
* @param Builder<Plugin> $query
*/
private static function applyBrowse(Builder $query, string $browse): void
{
// TODO: replicate 'featured' browse (currently it's identical to 'popular')
match ($browse) {
'new' => $query->reorder('added', 'desc'),
'updated' => $query->reorder('last_updated', 'desc'),
'top-rated', 'popular', 'featured' => $query->reorder('rating', 'desc'),
default => $query->reorder('active_installs', 'desc'),
};
}
}

View file

@ -2,10 +2,10 @@

namespace App\Services\Themes;

use App\Data\WpOrg\Themes\HotTagsResponse;
use App\Data\WpOrg\Themes\ThemeHotTagsResponse;
use App\Models\WpOrg\ThemeTag;

class HotTagsService
class ThemeHotTagsService
{
/**
* Gets the top tags by theme count
@ -28,6 +28,6 @@ class HotTagsService
'count' => (int) $tag->themes_count,
];
});
return HotTagsResponse::fromCollection($hotTags)->toArray();
return ThemeHotTagsResponse::fromCollection($hotTags)->toArray();
}
}

View file

@ -3,9 +3,9 @@
namespace App\Services\Themes;

use App\Data\WpOrg\Themes\ThemeInformationRequest;
use App\Exceptions\NotFoundException;
use App\Http\Resources\ThemeResource;
use App\Models\WpOrg\Theme;
use App\NotFoundException;

class ThemeInformationService
{

View file

@ -4,6 +4,7 @@ namespace Database\Factories\WpOrg;

use App\Models\Sync\SyncPlugin;
use App\Models\WpOrg\Plugin;
use App\Models\WpOrg\PluginTag;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

@ -50,7 +51,6 @@ class PluginFactory extends Factory
'low' => $this->faker->imageUrl(772, 250),
'high' => $this->faker->imageUrl(1544, 500),
],
'tags' => $this->generateTags(),
'donate_link' => $this->faker->optional()->url(),
'contributors' => $this->generateContributors(),
'icons' => [
@ -75,14 +75,6 @@ class PluginFactory extends Factory
];
}

protected function generateTags(): array
{
$possibleTags = ['seo', 'security', 'social-media', 'woocommerce', 'forms', 'widgets',
'admin', 'marketing', 'analytics', 'backup', 'cache', 'performance'];

return $this->faker->randomElements($possibleTags, $this->faker->numberBetween(2, 6));
}

protected function generateContributors(): array
{
$contributors = [];
@ -256,4 +248,38 @@ class PluginFactory extends Factory
'active_installs' => $this->faker->numberBetween(100000, 1000000),
]);
}

/**
* Configure the model factory to create a plugin with tags
*/
public function withTags(int $count = 3): static
{
return $this->afterCreating(function (Plugin $plugin) use ($count) {
$tags = PluginTag::factory()->count($count)->create();
$plugin->tags()->attach($tags->pluck('id'));
});
}

/**
* Configure the model factory to create a plugin with specific tags
* If tags already exist, they will be reused instead of creating duplicates
*/
public function withSpecificTags(array $tagNames): static
{
return $this->afterCreating(function (Plugin $plugin) use ($tagNames) {
$tags = collect($tagNames)->map(function ($tagName) {
$slug = Str::slug($tagName);

return PluginTag::query()->firstOrCreate(
['slug' => $slug],
[
'id' => $this->faker->uuid(),
'name' => $tagName,
]
);
});

$plugin->tags()->attach($tags->pluck('id'));
});
}
}

View file

@ -0,0 +1,24 @@
<?php

namespace Database\Factories\WpOrg;

use App\Models\WpOrg\PluginTag;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

class PluginTagFactory extends Factory
{
protected $model = PluginTag::class;

public function definition(): array
{
$name = $this->faker->words(3, true);
$slug = Str::slug($name);

return [
'id' => $this->faker->uuid(),
'slug' => $slug,
'name' => $name,
];
}
}

View file

@ -0,0 +1,27 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropColumn('tags');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->jsonb('tags')->nullable();
});
}
};

View file

@ -0,0 +1,41 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
public function up(): void
{
Schema::table('theme_theme_tags', function (Blueprint $table) {
$table->dropForeign('theme_theme_tags_theme_id_foreign');
$table->dropForeign('theme_theme_tags_theme_tag_id_foreign');
$table->foreign('theme_id')->references('id')->on('themes')->onDelete('cascade');
$table->foreign('theme_tag_id')->references('id')->on('theme_tags')->onDelete('cascade');
});

Schema::table('plugin_plugin_tags', function (Blueprint $table) {
$table->dropForeign('plugin_plugin_tags_plugin_id_foreign');
$table->dropForeign('plugin_plugin_tags_plugin_tag_id_foreign');
$table->foreign('plugin_id')->references('id')->on('plugins')->onDelete('cascade');
$table->foreign('plugin_tag_id')->references('id')->on('plugin_tags')->onDelete('cascade');
});
}

public function down(): void
{
Schema::table('theme_theme_tags', function (Blueprint $table) {
$table->dropForeign('theme_theme_tags_theme_id_foreign');
$table->dropForeign('theme_theme_tags_theme_tag_id_foreign');
$table->foreign('theme_id')->references('id')->on('themes')->onDelete('restrict');
$table->foreign('theme_tag_id')->references('id')->on('theme_tags')->onDelete('restrict');
});

Schema::table('plugin_plugin_tags', function (Blueprint $table) {
$table->dropForeign('plugin_plugin_tags_plugin_id_foreign');
$table->dropForeign('plugin_plugin_tags_plugin_tag_id_foreign');
$table->foreign('plugin_id')->references('id')->on('plugins')->onDelete('restrict');
$table->foreign('plugin_tag_id')->references('id')->on('plugin_tags')->onDelete('restrict');
});
}
};

View file

@ -1,2 +0,0 @@
grumphp:
tasks: { phpcsfixer: null }

6
grumphp.yml.dist Normal file
View file

@ -0,0 +1,6 @@
grumphp:
tasks:
phpcsfixer:
config: .php-cs-fixer.dist.php


View file

@ -2,6 +2,7 @@

// Note: api routes are not prefixed, i.e. all routes in here are from the root like web routes

use App\Http\Controllers\API\WpOrg\Core\ImportersController;
use App\Http\Controllers\API\WpOrg\Plugins\PluginInformation_1_2_Controller;
use App\Http\Controllers\API\WpOrg\Plugins\PluginUpdateCheck_1_1_Controller;
use App\Http\Controllers\API\WpOrg\SecretKey\SecretKeyController;
@ -38,7 +39,7 @@ $routeDefinition
$router->get('/core/checksums/{version}', CatchAllController::class)->where(['version' => '1.0']);
$router->get('/core/credits/{version}', CatchAllController::class)->where(['version' => '1.[01]']);
$router->get('/core/handbook/{version}', CatchAllController::class)->where(['version' => '1.0']);
$router->get('/core/importers/{version}', CatchAllController::class)->where(['version' => '1.[01]']);
$router->get('/core/importers/{version}', ImportersController::class)->where(['version' => '1.[01]']);
$router->get('/core/serve-happy/{version}', CatchAllController::class)->where(['version' => '1.0']);
$router->get('/core/stable-check/{version}', CatchAllController::class)->where(['version' => '1.0']);
$router->get('/core/version-check/{version}', CatchAllController::class)->where(['version' => '1.[67]']);

View file

@ -2,24 +2,8 @@

use App\Models\WpOrg\Plugin;

beforeEach(function () {
Plugin::factory()->create([
'name' => 'JWT Auth',
'slug' => 'jwt-auth',
'tags' => ['authentication', 'jwt', 'api'],
]);

Plugin::factory()->create([
'name' => 'JWT Authentication for WP-API',
'slug' => 'jwt-authentication-for-wp-rest-api',
'tags' => ['jwt', 'api', 'rest-api'],
'author' => 'tmeister',
]);

Plugin::factory()->count(8)->create();
});

it('returns 400 when slug is missing', function () {
Plugin::factory(10)->create();
$response = makeApiRequest('GET', '/plugins/info/1.2?action=plugin_information');

$response->assertStatus(400)
@ -29,6 +13,7 @@ it('returns 400 when slug is missing', function () {
});

it('returns 404 when plugin does not exist', function () {
Plugin::factory(10)->create();
$response = makeApiRequest('GET', '/plugins/info/1.2?action=plugin_information&slug=non-existent-plugin');

$response->assertStatus(404)
@ -38,6 +23,12 @@ it('returns 404 when plugin does not exist', function () {
});

it('returns plugin information in wp.org format', function () {
Plugin::factory()->create([
'name' => 'JWT Authentication for WP-API',
'slug' => 'jwt-authentication-for-wp-rest-api',
]);
Plugin::factory(9)->create();

$response = makeApiRequest('GET', '/plugins/info/1.2?action=plugin_information&slug=jwt-authentication-for-wp-rest-api');

$response->assertStatus(200)
@ -49,14 +40,15 @@ it('returns plugin information in wp.org format', function () {
});

it('returns search results by tag in wp.org format', function () {
$tag = 'jwt';
$tags = ['jwt', 'authentication', 'rest api'];
$tagToQuery = 'jwt';

Plugin::factory(8)->create();
Plugin::factory()->count(2)->withSpecificTags($tags)->create();

expect(Plugin::query()->count())->toBe(10);

$jwtPlugins = Plugin::query()->where('tags', 'ilike', '%' . $tag . '%')->count();
expect($jwtPlugins)->toBe(2);

$response = makeApiRequest('GET', '/plugins/info/1.2?action=query_plugins&tag=' . $tag);
$response = makeApiRequest('GET', '/plugins/info/1.2?action=query_plugins&tag=' . $tagToQuery);

$response->assertStatus(200);
assertWpPluginAPIStructureForSearch($response);
@ -74,12 +66,19 @@ it('returns search results by tag in wp.org format', function () {
->and($responseData['info']['results'])->toBe(2);

foreach ($responseData['plugins'] as $plugin) {
expect($plugin['tags'])->toContain($tag);
expect($plugin['tags'])->toContain($tagToQuery);
}
});

it('returns search results by query string in wp.org format', function () {
$query = 'jwt';
Plugin::factory()->create([
'name' => 'JWT Authentication for WP-API',
'slug' => 'jwt-authentication-for-wp-rest-api',
]);

Plugin::factory(9)->create();

expect(Plugin::query()->count())->toBe(10);

$response = makeApiRequest('GET', '/plugins/info/1.2?action=query_plugins&search=' . $query);
@ -88,7 +87,7 @@ it('returns search results by query string in wp.org format', function () {
assertWpPluginAPIStructureForSearch($response);

$responseData = $response->json();
expect(count($responseData['plugins']))->toBe(2)
expect(count($responseData['plugins']))->toBe(1)
->and($responseData['info'])->toHaveKeys([
'page',
'pages',
@ -96,16 +95,23 @@ it('returns search results by query string in wp.org format', function () {
])
->and($responseData['info']['page'])->toBe(1)
->and($responseData['info']['pages'])->toBe(1)
->and($responseData['info']['results'])->toBe(2);
->and($responseData['info']['results'])->toBe(1);
});

it('returns search results by tag and author in wp.org format', function () {
$tag = 'jwt';
$tags = ['jwt', 'authentication', 'rest api'];
$tagToQuery = 'jwt';
$author = 'tmeister';

Plugin::factory(9)->create();
Plugin::factory()->count(1)
->withSpecificTags($tags)->create([
'author' => $author,
]);

expect(Plugin::query()->count())->toBe(10);

$response = makeApiRequest('GET', '/plugins/info/1.2?action=query_plugins&tag=' . $tag . '&author=' . $author);
$response = makeApiRequest('GET', '/plugins/info/1.2?action=query_plugins&tag=' . $tagToQuery . '&author=' . $author);

$response->assertStatus(200);
assertWpPluginAPIStructureForSearch($response);
@ -126,6 +132,7 @@ it('returns a valid pagination', function () {
$perPage = 2;
$page = 2;

Plugin::factory(10)->create();
expect(Plugin::query()->count())->toBe(10);

$response = makeApiRequest('GET', '/plugins/info/1.2?action=query_plugins&per_page=' . $perPage . '&page=' . $page);

View file

@ -134,7 +134,6 @@ function assertWpPluginAPIStructure($response)
'support_threads',
'support_threads_resolved',
'active_installs',
'downloaded',
'last_updated',
'added',
'homepage',