diff --git a/.env.full b/.env.full index b5e2c53..c90208e 100644 --- a/.env.full +++ b/.env.full @@ -31,18 +31,6 @@ DB_PASSWORD=password DB_CHARSET=utf8mb4 DB_COLLATION=utf8mb4_0900_ai_ci -# This is only needed if you are running the `artisan app:import-hub` command. -# For normal development you should just seed the database with fake data by -# running the command: `php artisan migrate:fresh --seed` -DB_HUB_CONNECTION=mysql -DB_HUB_HOST= -DB_HUB_PORT= -DB_HUB_DATABASE= -DB_HUB_USERNAME= -DB_HUB_PASSWORD= -DB_HUB_CHARSET=utf8mb4 -DB_HUB_COLLATION=utf8mb4_0900_ai_ci - SESSION_DRIVER=redis SESSION_CONNECTION=default SESSION_LIFETIME=120 @@ -86,3 +74,18 @@ OCTANE_SERVER=swoole OCTANE_HTTPS=false SAIL_XDEBUG_MODE=develop,debug,coverage + +# Everything below is only needed if you are running the `artisan app:import-hub` command. +# For normal development you should just seed the database with fake data by +# running the command: `php artisan migrate:fresh --seed` +DB_HUB_CONNECTION=mysql +DB_HUB_HOST= +DB_HUB_PORT= +DB_HUB_DATABASE= +DB_HUB_USERNAME= +DB_HUB_PASSWORD= +DB_HUB_CHARSET=utf8mb4 +DB_HUB_COLLATION=utf8mb4_0900_ai_ci + +GITEA_DOMAIN= +GITEA_TOKEN= diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..8fdd954 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/app/Console/Commands/ImportHub.php b/app/Console/Commands/ImportHubCommand.php similarity index 68% rename from app/Console/Commands/ImportHub.php rename to app/Console/Commands/ImportHubCommand.php index 7a95264..5c1ed2b 100644 --- a/app/Console/Commands/ImportHub.php +++ b/app/Console/Commands/ImportHubCommand.php @@ -2,10 +2,10 @@ namespace App\Console\Commands; -use App\Jobs\ImportHubData; +use App\Jobs\ImportHubDataJob; use Illuminate\Console\Command; -class ImportHub extends Command +class ImportHubCommand extends Command { protected $signature = 'app:import-hub'; @@ -13,8 +13,7 @@ class ImportHub extends Command public function handle(): void { - // Add the ImportHubData job to the queue. - ImportHubData::dispatch()->onQueue('long'); + ImportHubDataJob::dispatch()->onQueue('long'); $this->info('The import job has been added to the queue.'); } diff --git a/app/Console/Commands/ResolveVersionsCommand.php b/app/Console/Commands/ResolveVersionsCommand.php new file mode 100644 index 0000000..bf8bd88 --- /dev/null +++ b/app/Console/Commands/ResolveVersionsCommand.php @@ -0,0 +1,22 @@ +onQueue('long'); + ResolveDependenciesJob::dispatch()->onQueue('long'); + + $this->info('The import job has been added to the queue.'); + } +} diff --git a/app/Console/Commands/SearchSync.php b/app/Console/Commands/SearchSyncCommand.php similarity index 94% rename from app/Console/Commands/SearchSync.php rename to app/Console/Commands/SearchSyncCommand.php index 9ba27b4..626070f 100644 --- a/app/Console/Commands/SearchSync.php +++ b/app/Console/Commands/SearchSyncCommand.php @@ -5,7 +5,7 @@ namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; -class SearchSync extends Command +class SearchSyncCommand extends Command { protected $signature = 'app:search-sync'; diff --git a/app/Console/Commands/UploadAssets.php b/app/Console/Commands/UploadAssetsCommand.php similarity index 97% rename from app/Console/Commands/UploadAssets.php rename to app/Console/Commands/UploadAssetsCommand.php index bd69b0d..711e81a 100644 --- a/app/Console/Commands/UploadAssets.php +++ b/app/Console/Commands/UploadAssetsCommand.php @@ -6,7 +6,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; -class UploadAssets extends Command +class UploadAssetsCommand extends Command { protected $signature = 'app:upload-assets'; diff --git a/app/Exceptions/CircularDependencyException.php b/app/Exceptions/CircularDependencyException.php index d66b945..2671956 100644 --- a/app/Exceptions/CircularDependencyException.php +++ b/app/Exceptions/CircularDependencyException.php @@ -4,7 +4,4 @@ namespace App\Exceptions; use Exception; -class CircularDependencyException extends Exception -{ - protected $message = 'Circular dependency detected.'; -} +class CircularDependencyException extends Exception {} diff --git a/app/Exceptions/InvalidVersionNumberException.php b/app/Exceptions/InvalidVersionNumberException.php index 34ff34d..9fd1749 100644 --- a/app/Exceptions/InvalidVersionNumberException.php +++ b/app/Exceptions/InvalidVersionNumberException.php @@ -4,7 +4,4 @@ namespace App\Exceptions; use Exception; -class InvalidVersionNumberException extends Exception -{ - protected $message = 'The version number is an invalid semantic version.'; -} +class InvalidVersionNumberException extends Exception {} diff --git a/app/Http/Controllers/ModController.php b/app/Http/Controllers/ModController.php index 86b89ee..777f095 100644 --- a/app/Http/Controllers/ModController.php +++ b/app/Http/Controllers/ModController.php @@ -45,7 +45,7 @@ class ModController extends Controller $this->authorize('view', $mod); - $latestVersion = $mod->versions->sortByDesc('version')->first(); + $latestVersion = $mod->versions->first(); return view('mod.show', compact(['mod', 'latestVersion'])); } diff --git a/app/Http/Filters/ModFilter.php b/app/Http/Filters/ModFilter.php index a92695f..a162a94 100644 --- a/app/Http/Filters/ModFilter.php +++ b/app/Http/Filters/ModFilter.php @@ -31,8 +31,7 @@ class ModFilter { return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at']) ->withTotalDownloads() - ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']) - ->whereHas('latestVersion'); + ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']); } /** @@ -98,9 +97,10 @@ class ModFilter */ private function sptVersion(array $versions): Builder { - return $this->builder->withWhereHas('latestVersion.sptVersion', function ($query) use ($versions) { - $query->whereIn('version', $versions); - $query->orderByDesc('version'); + return $this->builder->whereHas('latestVersion', function ($query) use ($versions) { + $query->whereHas('sptVersion', function ($query) use ($versions) { + $query->whereIn('version', $versions); + }); }); } } diff --git a/app/Jobs/ImportHubData.php b/app/Jobs/ImportHubDataJob.php similarity index 78% rename from app/Jobs/ImportHubData.php rename to app/Jobs/ImportHubDataJob.php index bac52eb..f6b9dc6 100644 --- a/app/Jobs/ImportHubData.php +++ b/app/Jobs/ImportHubDataJob.php @@ -28,7 +28,7 @@ use Illuminate\Support\Str; use League\HTMLToMarkdown\HtmlConverter; use Stevebauman\Purify\Facades\Purify; -class ImportHubData implements ShouldBeUnique, ShouldQueue +class ImportHubDataJob implements ShouldBeUnique, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -42,6 +42,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue $this->bringFileContentLocal(); $this->bringFileVersionLabelsLocal(); $this->bringFileVersionContentLocal(); + $this->bringSptVersionTagsLocal(); // Begin to import the data into the permanent local database tables. $this->importUsers(); @@ -53,9 +54,8 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue // Ensure that we've disconnected from the Hub database, clearing temporary tables. DB::connection('mysql_hub')->disconnect(); - // Re-sync search. Artisan::call('app:search-sync'); - + Artisan::call('app:resolve-versions'); Artisan::call('cache:clear'); } @@ -70,19 +70,24 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue avatarExtension VARCHAR(255), userID INT, fileHash VARCHAR(255) - )'); + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); DB::connection('mysql_hub') ->table('wcf1_user_avatar') ->orderBy('avatarID') ->chunk(200, function ($avatars) { + $insertData = []; foreach ($avatars as $avatar) { - DB::table('temp_user_avatar')->insert([ + $insertData[] = [ 'avatarID' => (int) $avatar->avatarID, 'avatarExtension' => $avatar->avatarExtension, 'userID' => (int) $avatar->userID, 'fileHash' => $avatar->fileHash, - ]); + ]; + } + + if ($insertData) { + DB::table('temp_user_avatar')->insert($insertData); } }); } @@ -93,17 +98,25 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue protected function bringFileAuthorsLocal(): void { DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_author'); - DB::statement('CREATE TEMPORARY TABLE temp_file_author (fileID INT, userID INT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); + DB::statement('CREATE TEMPORARY TABLE temp_file_author ( + fileID INT, + userID INT + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); DB::connection('mysql_hub') ->table('filebase1_file_author') ->orderBy('fileID') ->chunk(200, function ($relationships) { + $insertData = []; foreach ($relationships as $relationship) { - DB::table('temp_file_author')->insert([ + $insertData[] = [ 'fileID' => (int) $relationship->fileID, 'userID' => (int) $relationship->userID, - ]); + ]; + } + + if ($insertData) { + DB::table('temp_file_author')->insert($insertData); } }); } @@ -114,18 +127,27 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue protected function bringFileOptionsLocal(): void { DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values'); - DB::statement('CREATE TEMPORARY TABLE temp_file_option_values (fileID INT, optionID INT, optionValue VARCHAR(255)) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); + DB::statement('CREATE TEMPORARY TABLE temp_file_option_values ( + fileID INT, + optionID INT, + optionValue VARCHAR(255) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); DB::connection('mysql_hub') ->table('filebase1_file_option_value') ->orderBy('fileID') ->chunk(200, function ($options) { + $insertData = []; foreach ($options as $option) { - DB::table('temp_file_option_values')->insert([ + $insertData[] = [ 'fileID' => (int) $option->fileID, 'optionID' => (int) $option->optionID, 'optionValue' => $option->optionValue, - ]); + ]; + } + + if ($insertData) { + DB::table('temp_file_option_values')->insert($insertData); } }); } @@ -136,19 +158,29 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue protected function bringFileContentLocal(): void { DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); - DB::statement('CREATE TEMPORARY TABLE temp_file_content (fileID INT, subject VARCHAR(255), teaser VARCHAR(255), message LONGTEXT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); + DB::statement('CREATE TEMPORARY TABLE temp_file_content ( + fileID INT, + subject VARCHAR(255), + teaser VARCHAR(255), + message LONGTEXT + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); DB::connection('mysql_hub') ->table('filebase1_file_content') ->orderBy('fileID') ->chunk(200, function ($contents) { + $insertData = []; foreach ($contents as $content) { - DB::table('temp_file_content')->insert([ + $insertData[] = [ 'fileID' => (int) $content->fileID, 'subject' => $content->subject, 'teaser' => $content->teaser, 'message' => $content->message, - ]); + ]; + } + + if ($insertData) { + DB::table('temp_file_content')->insert($insertData); } }); } @@ -159,18 +191,26 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue protected function bringFileVersionLabelsLocal(): void { DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels'); - DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels (labelID INT, objectID INT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); + DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels ( + labelID INT, + objectID INT + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); DB::connection('mysql_hub') ->table('wcf1_label_object') ->where('objectTypeID', 387) ->orderBy('labelID') ->chunk(200, function ($options) { + $insertData = []; foreach ($options as $option) { - DB::table('temp_file_version_labels')->insert([ + $insertData[] = [ 'labelID' => (int) $option->labelID, 'objectID' => (int) $option->objectID, - ]); + ]; + } + + if ($insertData) { + DB::table('temp_file_version_labels')->insert($insertData); } }); } @@ -181,17 +221,54 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue protected function bringFileVersionContentLocal(): void { DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content'); - DB::statement('CREATE TEMPORARY TABLE temp_file_version_content (versionID INT, description TEXT) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); + DB::statement('CREATE TEMPORARY TABLE temp_file_version_content ( + versionID INT, + description TEXT + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); DB::connection('mysql_hub') ->table('filebase1_file_version_content') ->orderBy('versionID') ->chunk(200, function ($options) { + $insertData = []; foreach ($options as $option) { - DB::table('temp_file_version_content')->insert([ + $insertData[] = [ 'versionID' => (int) $option->versionID, 'description' => $option->description, - ]); + ]; + } + + if ($insertData) { + DB::table('temp_file_version_content')->insert($insertData); + } + }); + } + + private function bringSptVersionTagsLocal(): void + { + DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_spt_version_tags'); + DB::statement('CREATE TEMPORARY TABLE temp_spt_version_tags ( + hub_id INT, + version VARCHAR(255), + color_class VARCHAR(255) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci'); + + DB::connection('mysql_hub') + ->table('wcf1_label') + ->where('groupID', 1) + ->orderBy('labelID') + ->chunk(100, function (Collection $versions) { + $insertData = []; + foreach ($versions as $version) { + $insertData[] = [ + 'hub_id' => (int) $version->labelID, + 'version' => $version->label, + 'color_class' => $version->cssClassName, + ]; + } + + if ($insertData) { + DB::table('temp_spt_version_tags')->insert($insertData); } }); } @@ -531,40 +608,142 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue /** * Import the SPT versions from the Hub database to the local database. + * + * @throws Exception */ protected function importSptVersions(): void { - DB::connection('mysql_hub') - ->table('wcf1_label') - ->where('groupID', 1) - ->chunkById(100, function (Collection $versions) { - $insertData = []; - foreach ($versions as $version) { - $insertData[] = [ - 'hub_id' => (int) $version->labelID, - 'version' => $version->label, - 'color_class' => $this->translateColour($version->cssClassName), - ]; - } + $domain = config('services.gitea.domain'); + $token = config('services.gitea.token'); - if (! empty($insertData)) { - DB::table('spt_versions')->upsert($insertData, ['hub_id'], ['version', 'color_class']); - } - }, 'labelID'); + if (empty($domain) || empty($token)) { + return; + } + + $url = "{$domain}/api/v1/repos/SPT/Stable-releases/releases?draft=false&pre-release=false&token={$token}"; + + $response = json_decode(file_get_contents($url), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('JSON Decode Error: '.json_last_error_msg()); + } + + if (empty($response)) { + throw new Exception('No version data found in the API response.'); + } + + $latestVersion = $this->getLatestVersion($response); + + $insertData = []; + foreach ($response as $version) { + $insertData[] = [ + 'version' => $version['tag_name'], + 'link' => $version['html_url'], + 'color_class' => $this->detectVersionColor($version['tag_name'], $latestVersion), + 'created_at' => Carbon::parse($version['created_at'], 'UTC'), + 'updated_at' => Carbon::parse($version['created_at'], 'UTC'), + ]; + } + + // Add a fake 0.0.0 version for outdated mods. + $insertData[] = [ + 'version' => '0.0.0', + 'link' => '', + 'color_class' => 'black', + 'created_at' => Carbon::now('UTC'), + 'updated_at' => Carbon::now('UTC'), + ]; + + // Upsert won't work here. Do it manually. :( + foreach ($insertData as $data) { + $existingVersion = SptVersion::where('version', $data['version'])->first(); + if ($existingVersion) { + $existingVersion->update([ + 'link' => $data['link'], + 'color_class' => $data['color_class'], + 'created_at' => $data['created_at'], + 'updated_at' => $data['updated_at'], + ]); + } else { + SptVersion::create($data); + } + } } /** - * Translate the colour class from the Hub database to the local database. + * Get the latest current version from the response data. */ - protected function translateColour(string $colour = ''): string + protected function getLatestVersion(array $versions): string { - return match ($colour) { - 'green' => 'green', - 'slightly-outdated' => 'lime', - 'yellow' => 'yellow', - 'red' => 'red', - default => 'gray', - }; + $semanticVersions = array_map( + fn ($version) => $this->extractSemanticVersion($version['tag_name']), + $versions + ); + + usort($semanticVersions, 'version_compare'); + + return end($semanticVersions); + } + + /** + * Extract the last semantic version from a string. + * If the version has no patch number, return it as `~..0`. + */ + protected function extractSemanticVersion(string $versionString, bool $appendPatch = false): ?string + { + // Match both two-part and three-part semantic versions + preg_match_all('/\b\d+\.\d+(?:\.\d+)?\b/', $versionString, $matches); + + // Get the last version found, if any + $version = end($matches[0]) ?: null; + + if (! $appendPatch) { + return $version; + } + + // If version is two-part (e.g., "3.9"), add ".0" and prefix with "~" + if ($version && preg_match('/^\d+\.\d+$/', $version)) { + $version = '~'.$version.'.0'; + } + + return $version; + } + + /** + * Translate the version string into a color class. + */ + protected function detectVersionColor(string $versionString, string $currentVersion): string + { + $version = $this->extractSemanticVersion($versionString); + if (! $version) { + return 'gray'; + } + + if ($version === '0.0.0') { + return 'black'; + } + + [$currentMajor, $currentMinor] = explode('.', $currentVersion); + [$major, $minor] = explode('.', $version); + + $currentMajor = (int) $currentMajor; + $currentMinor = (int) $currentMinor; + $major = (int) $major; + $minor = (int) $minor; + + if ($major == $currentMajor) { + $difference = $currentMinor - $minor; + + return match ($difference) { + 0 => 'green', + 1 => 'lime', + 2 => 'yellow', + 3 => 'red', + default => 'gray', + }; + } + + return 'gray'; } /** @@ -748,17 +927,22 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue continue; } + // Fetch the version string using the labelID from the hub. + $sptVersionTemp = DB::table('temp_spt_version_tags')->where('hub_id', $versionLabel->labelID)->value('version'); + $sptVersionConstraint = $this->extractSemanticVersion($sptVersionTemp, appendPatch: true) ?? '0.0.0'; + $insertData[] = [ 'hub_id' => (int) $version->versionID, 'mod_id' => $modId, - 'version' => $version->versionNumber, + 'version' => $this->extractSemanticVersion($version->versionNumber) ?? '0.0.0', 'description' => $this->cleanHubContent($versionContent->description ?? ''), 'link' => $version->downloadURL, - 'spt_version_id' => SptVersion::whereHubId($versionLabel->labelID)->value('id'), + 'spt_version_constraint' => $sptVersionConstraint, + 'resolved_spt_version_id' => null, 'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '', 'downloads' => max((int) $version->downloads, 0), // At least 0. 'disabled' => (bool) $version->isDisabled, - 'published_at' => Carbon::parse($version->uploadTime, 'UTC'), + 'published_at' => $sptVersionConstraint === '0.0.0' ? null : Carbon::parse($version->uploadTime, 'UTC'), 'created_at' => Carbon::parse($version->uploadTime, 'UTC'), 'updated_at' => Carbon::parse($version->uploadTime, 'UTC'), ]; @@ -770,7 +954,8 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue 'version', 'description', 'link', - 'spt_version_id', + 'spt_version_constraint', + 'resolved_spt_version_id', 'virus_total_link', 'downloads', 'published_at', @@ -793,6 +978,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content'); + DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_spt_version_tags'); // Close the connections. This should drop the temporary tables as well, but I like to be explicit. DB::connection('mysql_hub')->disconnect(); diff --git a/app/Jobs/ResolveDependenciesJob.php b/app/Jobs/ResolveDependenciesJob.php new file mode 100644 index 0000000..8f5024b --- /dev/null +++ b/app/Jobs/ResolveDependenciesJob.php @@ -0,0 +1,29 @@ +resolve($modVersion); + } + } +} diff --git a/app/Jobs/ResolveSptVersionsJob.php b/app/Jobs/ResolveSptVersionsJob.php new file mode 100644 index 0000000..88d5e57 --- /dev/null +++ b/app/Jobs/ResolveSptVersionsJob.php @@ -0,0 +1,29 @@ +resolve($modVersion); + } + } +} diff --git a/app/Livewire/Mod/Index.php b/app/Livewire/Mod/Index.php index 74f4daf..92d1fbb 100644 --- a/app/Livewire/Mod/Index.php +++ b/app/Livewire/Mod/Index.php @@ -49,7 +49,10 @@ class Index extends Component */ public function mount(): void { + // TODO: This should be updated to only pull versions that have mods associated with them. + // To do this, the ModVersion to SptVersion relationship needs to be converted to a many-to-many relationship. Ugh. $this->availableSptVersions = SptVersion::select(['id', 'version', 'color_class'])->orderByDesc('version')->get(); + $this->sptVersion = $this->getLatestMinorVersions()->pluck('version')->toArray(); } @@ -75,7 +78,7 @@ class Index extends Component 'order' => $this->order, 'sptVersion' => $this->sptVersion, ]; - $mods = (new ModFilter($filters))->apply()->paginate(24); + $mods = (new ModFilter($filters))->apply()->paginate(16); return view('livewire.mod.index', compact('mods')); } diff --git a/app/Models/Mod.php b/app/Models/Mod.php index b0cd756..57596b0 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -58,9 +58,16 @@ class Mod extends Model /** * The relationship between a mod and its versions. */ - public function versions(): HasMany + public function versions(bool $resolvedOnly = true): HasMany { - return $this->hasMany(ModVersion::class)->orderByDesc('version'); + $relation = $this->hasMany(ModVersion::class) + ->orderByDesc('version'); + + if ($resolvedOnly) { + $relation->whereNotNull('resolved_spt_version_id'); + } + + return $relation; } /** @@ -77,10 +84,16 @@ class Mod extends Model /** * The relationship between a mod and its last updated version. */ - public function lastUpdatedVersion(): HasOne + public function lastUpdatedVersion(bool $resolvedOnly = true): HasOne { - return $this->hasOne(ModVersion::class) + $relation = $this->hasOne(ModVersion::class) ->orderByDesc('updated_at'); + + if ($resolvedOnly) { + $relation->whereNotNull('resolved_spt_version_id'); + } + + return $relation; } /** @@ -108,12 +121,18 @@ class Mod extends Model /** * The relationship to the latest mod version, dictated by the mod version number. */ - public function latestVersion(): HasOne + public function latestVersion(bool $resolvedOnly = true): HasOne { - return $this->hasOne(ModVersion::class) + $relation = $this->hasOne(ModVersion::class) ->orderByDesc('version') ->orderByDesc('updated_at') ->take(1); + + if ($resolvedOnly) { + $relation->whereNotNull('resolved_spt_version_id'); + } + + return $relation; } /** diff --git a/app/Models/ModDependency.php b/app/Models/ModDependency.php index 81df44f..47430da 100644 --- a/app/Models/ModDependency.php +++ b/app/Models/ModDependency.php @@ -26,7 +26,7 @@ class ModDependency extends Model } /** - * The relationship between a mod dependency and mod. + * The relationship between the mod dependency and the mod that is depended on. */ public function dependencyMod(): BelongsTo { diff --git a/app/Models/ModVersion.php b/app/Models/ModVersion.php index faaea23..149d9d9 100644 --- a/app/Models/ModVersion.php +++ b/app/Models/ModVersion.php @@ -39,9 +39,15 @@ class ModVersion extends Model /** * The relationship between a mod version and its dependencies. */ - public function dependencies(): HasMany + public function dependencies(bool $resolvedOnly = true): HasMany { - return $this->hasMany(ModDependency::class); + $relation = $this->hasMany(ModDependency::class); + + if ($resolvedOnly) { + $relation->whereNotNull('resolved_version_id'); + } + + return $relation; } /** @@ -49,6 +55,6 @@ class ModVersion extends Model */ public function sptVersion(): BelongsTo { - return $this->belongsTo(SptVersion::class); + return $this->belongsTo(SptVersion::class, 'resolved_spt_version_id'); } } diff --git a/app/Models/SptVersion.php b/app/Models/SptVersion.php index ca66515..b1a27f7 100644 --- a/app/Models/SptVersion.php +++ b/app/Models/SptVersion.php @@ -36,22 +36,21 @@ class SptVersion extends Model } try { - $currentMinorVersion = $this->extractMinorVersion($this->version); - $latestMinorVersion = $this->extractMinorVersion($latestVersion->version); + [$currentMajor, $currentMinor, $currentPatch] = $this->extractVersionParts($this->version); + [$latestMajor, $latestMinor, $latestPatch] = $this->extractVersionParts($latestVersion->version); } catch (InvalidVersionNumberException $e) { - // Could not parse a semver version number. return false; } - return $currentMinorVersion === $latestMinorVersion; + return $currentMajor == $latestMajor && $currentMinor === $latestMinor; } /** - * Extract the minor version from a full version string. + * Extract the version components from a full version string. * * @throws InvalidVersionNumberException */ - private function extractMinorVersion(string $version): int + private function extractVersionParts(string $version): array { // Remove everything from the version string except the numbers and dots. $version = preg_replace('/[^0-9.]/', '', $version); @@ -63,7 +62,10 @@ class SptVersion extends Model $parts = explode('.', $version); - // Return the minor version part. - return (int) $parts[1]; + return [ + (int) $parts[0], + (int) $parts[1], + (int) $parts[2], + ]; } } diff --git a/app/Observers/ModDependencyObserver.php b/app/Observers/ModDependencyObserver.php index 2d6eeff..353e873 100644 --- a/app/Observers/ModDependencyObserver.php +++ b/app/Observers/ModDependencyObserver.php @@ -2,32 +2,50 @@ namespace App\Observers; +use App\Exceptions\CircularDependencyException; use App\Models\ModDependency; use App\Models\ModVersion; -use App\Services\ModVersionService; +use App\Services\DependencyVersionService; class ModDependencyObserver { - protected ModVersionService $modVersionService; + protected DependencyVersionService $dependencyVersionService; - public function __construct(ModVersionService $modVersionService) + public function __construct(DependencyVersionService $dependencyVersionService) { - $this->modVersionService = $modVersionService; + $this->dependencyVersionService = $dependencyVersionService; } + /** + * Handle the ModDependency "saved" event. + * + * @throws CircularDependencyException + */ public function saved(ModDependency $modDependency): void { - $modVersion = ModVersion::find($modDependency->mod_version_id); - if ($modVersion) { - $this->modVersionService->resolveDependencies($modVersion); - } + $this->resolveDependencyVersion($modDependency); } - public function deleted(ModDependency $modDependency): void + /** + * Resolve the ModDependency's dependencies. + * + * @throws CircularDependencyException + */ + public function resolveDependencyVersion(ModDependency $modDependency): void { $modVersion = ModVersion::find($modDependency->mod_version_id); if ($modVersion) { - $this->modVersionService->resolveDependencies($modVersion); + $this->dependencyVersionService->resolve($modVersion); } } + + /** + * Handle the ModDependency "deleted" event. + * + * @throws CircularDependencyException + */ + public function deleted(ModDependency $modDependency): void + { + $this->resolveDependencyVersion($modDependency); + } } diff --git a/app/Observers/ModVersionObserver.php b/app/Observers/ModVersionObserver.php index 4d4195a..abd2ecd 100644 --- a/app/Observers/ModVersionObserver.php +++ b/app/Observers/ModVersionObserver.php @@ -2,32 +2,57 @@ namespace App\Observers; +use App\Exceptions\CircularDependencyException; use App\Models\ModDependency; use App\Models\ModVersion; -use App\Services\ModVersionService; +use App\Services\DependencyVersionService; +use App\Services\SptVersionService; class ModVersionObserver { - protected ModVersionService $modVersionService; + protected DependencyVersionService $dependencyVersionService; - public function __construct(ModVersionService $modVersionService) - { - $this->modVersionService = $modVersionService; + protected SptVersionService $sptVersionService; + + public function __construct( + DependencyVersionService $dependencyVersionService, + SptVersionService $sptVersionService, + ) { + $this->dependencyVersionService = $dependencyVersionService; + $this->sptVersionService = $sptVersionService; } + /** + * Handle the ModVersion "saved" event. + * + * @throws CircularDependencyException + */ public function saved(ModVersion $modVersion): void { - $dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get(); - foreach ($dependencies as $dependency) { - $this->modVersionService->resolveDependencies($dependency->modVersion); - } + $this->resolveDependencyVersion($modVersion); + $this->sptVersionService->resolve($modVersion); } - public function deleted(ModVersion $modVersion): void + /** + * Resolve the ModVersion's dependencies. + * + * @throws CircularDependencyException + */ + private function resolveDependencyVersion(ModVersion $modVersion): void { $dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get(); foreach ($dependencies as $dependency) { - $this->modVersionService->resolveDependencies($dependency->modVersion); + $this->dependencyVersionService->resolve($dependency->modVersion); } } + + /** + * Handle the ModVersion "deleted" event. + * + * @throws CircularDependencyException + */ + public function deleted(ModVersion $modVersion): void + { + $this->resolveDependencyVersion($modVersion); + } } diff --git a/app/Observers/SptVersionObserver.php b/app/Observers/SptVersionObserver.php new file mode 100644 index 0000000..1b547fa --- /dev/null +++ b/app/Observers/SptVersionObserver.php @@ -0,0 +1,44 @@ +sptVersionService = $sptVersionService; + } + + /** + * Handle the SptVersion "saved" event. + */ + public function saved(): void + { + $this->resolveSptVersion(); + } + + /** + * Resolve the SptVersion's dependencies. + */ + private function resolveSptVersion(): void + { + $modVersions = ModVersion::all(); + + foreach ($modVersions as $modVersion) { + $this->sptVersionService->resolve($modVersion); + } + } + + /** + * Handle the SptVersion "deleted" event. + */ + public function deleted(): void + { + $this->resolveSptVersion(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 136e052..d52f31b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,9 +4,11 @@ namespace App\Providers; use App\Models\ModDependency; use App\Models\ModVersion; +use App\Models\SptVersion; use App\Models\User; use App\Observers\ModDependencyObserver; use App\Observers\ModVersionObserver; +use App\Observers\SptVersionObserver; use App\Services\LatestSptVersionService; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Gate; @@ -36,6 +38,7 @@ class AppServiceProvider extends ServiceProvider // Register observers. ModVersion::observe(ModVersionObserver::class); ModDependency::observe(ModDependencyObserver::class); + SptVersion::observe(SptVersionObserver::class); // This gate determines who can access the Pulse dashboard. Gate::define('viewPulse', function (User $user) { diff --git a/app/Services/DependencyVersionService.php b/app/Services/DependencyVersionService.php new file mode 100644 index 0000000..d9f7b0c --- /dev/null +++ b/app/Services/DependencyVersionService.php @@ -0,0 +1,119 @@ +visited = []; + $this->stack = []; + + // Store the resolved versions for each dependency. + $resolvedVersions = []; + + // Start the recursive depth-first search to resolve dependencies. + $this->processDependencies($modVersion, $resolvedVersions); + + return $resolvedVersions; + } + + /** + * Perform a depth-first search to resolve dependencies for the given mod version. + * + * @throws CircularDependencyException + */ + protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void + { + // Detect circular dependencies + if (in_array($modVersion->id, $this->stack)) { + throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}"); + } + + // Skip already processed versions + if (in_array($modVersion->id, $this->visited)) { + return; + } + + // Mark the current version + $this->visited[] = $modVersion->id; + $this->stack[] = $modVersion->id; + + // Get the dependencies for the current mod version. + $dependencies = $modVersion->dependencies(resolvedOnly: false)->get(); + + foreach ($dependencies as $dependency) { + // Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency. + $resolvedId = $this->resolveDependency($dependency); + + // Update the resolved version ID for the dependency if it has changed. + // Do it "quietly" to avoid triggering the observer again. + if ($dependency->resolved_version_id !== $resolvedId) { + $dependency->updateQuietly(['resolved_version_id' => $resolvedId]); + } + + // At this point, the dependency has been resolved (or not) and we can add it to the resolved versions to + // avoid resolving it again in the future and to help with circular dependency detection. + $resolvedVersions[$dependency->id] = $resolvedId ? ModVersion::find($resolvedId) : null; + + // Recursively process the resolved dependency. + if ($resolvedId) { + $nextModVersion = ModVersion::find($resolvedId); + if ($nextModVersion) { + $this->processDependencies($nextModVersion, $resolvedVersions); + } + } + } + + // Remove the current version from the stack now that we have processed all its dependencies. + array_pop($this->stack); + } + + /** + * Resolve the latest mod version ID that satisfies the version constraint on the mod version dependency. + */ + protected function resolveDependency(ModDependency $dependency): ?int + { + $dependencyModVersions = $dependency->dependencyMod->versions(resolvedOnly: false); + + // There are no mod versions for the dependency mod. + if ($dependencyModVersions->doesntExist()) { + return null; + } + + $availableVersions = $dependencyModVersions->pluck('id', 'version')->toArray(); + $satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint); + + // There are no mod versions that satisfy the version constraint. + if (empty($satisfyingVersions)) { + return null; + } + + // Sort the satisfying versions in descending order using the version_compare function. + usort($satisfyingVersions, 'version_compare'); + $satisfyingVersions = array_reverse($satisfyingVersions); + + // Return the latest (highest version number) satisfying version. + return $availableVersions[$satisfyingVersions[0]]; + } +} diff --git a/app/Services/LatestSptVersionService.php b/app/Services/LatestSptVersionService.php index eb4e15a..29cc771 100644 --- a/app/Services/LatestSptVersionService.php +++ b/app/Services/LatestSptVersionService.php @@ -4,6 +4,10 @@ namespace App\Services; use App\Models\SptVersion; +/** + * This class is responsible for fetching the latest SPT version. It's registered as a singleton in the service + * container so that the latest version is only fetched once per request. + */ class LatestSptVersionService { protected ?SptVersion $version = null; diff --git a/app/Services/ModVersionService.php b/app/Services/ModVersionService.php deleted file mode 100644 index 7d6c9cc..0000000 --- a/app/Services/ModVersionService.php +++ /dev/null @@ -1,99 +0,0 @@ -visited = []; - $this->stack = []; - - $this->processDependencies($modVersion, $resolvedVersions); - - return $resolvedVersions; - } - - /** - * Perform a depth-first search to resolve dependencies for the given mod version. - * - * @throws CircularDependencyException - */ - protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void - { - if (in_array($modVersion->id, $this->stack)) { - throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}"); - } - - if (in_array($modVersion->id, $this->visited)) { - return; // Skip already processed versions - } - - $this->visited[] = $modVersion->id; - $this->stack[] = $modVersion->id; - - /** @var Collection|ModDependency[] $dependencies */ - $dependencies = $this->getDependencies($modVersion); - - foreach ($dependencies as $dependency) { - $resolvedVersionId = $this->resolveVersionIdForDependency($dependency); - - if ($dependency->resolved_version_id !== $resolvedVersionId) { - $dependency->updateQuietly(['resolved_version_id' => $resolvedVersionId]); - } - - $resolvedVersions[$dependency->id] = $resolvedVersionId ? ModVersion::find($resolvedVersionId) : null; - - if ($resolvedVersionId) { - $nextModVersion = ModVersion::find($resolvedVersionId); - if ($nextModVersion) { - $this->processDependencies($nextModVersion, $resolvedVersions); - } - } - } - - array_pop($this->stack); - } - - /** - * Get the dependencies for the given mod version. - */ - protected function getDependencies(ModVersion $modVersion): Collection - { - return $modVersion->dependencies()->with(['dependencyMod.versions'])->get(); - } - - /** - * Resolve the latest version ID that satisfies the version constraint on given dependency. - */ - protected function resolveVersionIdForDependency(ModDependency $dependency): ?int - { - $mod = $dependency->dependencyMod; - - if (! $mod || $mod->versions->isEmpty()) { - return null; - } - - $availableVersions = $mod->versions->pluck('id', 'version')->toArray(); - $satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint); - - // Versions are sorted in descending order by default. Take the first key (the latest version) using `reset()`. - return $satisfyingVersions ? $availableVersions[reset($satisfyingVersions)] : null; - } -} diff --git a/app/Services/SptVersionService.php b/app/Services/SptVersionService.php new file mode 100644 index 0000000..c480d50 --- /dev/null +++ b/app/Services/SptVersionService.php @@ -0,0 +1,42 @@ +resolved_spt_version_id = $this->satisfyconstraint($modVersion); + $modVersion->saveQuietly(); + } + + /** + * Satisfies the version constraint of a given ModVersion. Returns the ID of the satisfying SptVersion. + */ + private function satisfyConstraint(ModVersion $modVersion): ?int + { + $availableVersions = SptVersion::query() + ->orderBy('version', 'desc') + ->pluck('id', 'version') + ->toArray(); + + $satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $modVersion->spt_version_constraint); + if (empty($satisfyingVersions)) { + return null; + } + + // Ensure the satisfying versions are sorted in descending order to get the latest version + usort($satisfyingVersions, 'version_compare'); + $satisfyingVersions = array_reverse($satisfyingVersions); + + // Return the ID of the latest satisfying version + return $availableVersions[$satisfyingVersions[0]]; + } +} diff --git a/app/View/Components/ModListSection.php b/app/View/Components/ModListSection.php index ce1c782..23950d5 100644 --- a/app/View/Components/ModListSection.php +++ b/app/View/Components/ModListSection.php @@ -30,7 +30,6 @@ class ModListSection extends Component return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) ->withTotalDownloads() ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']) - ->whereHas('latestVersion') ->where('featured', true) ->latest() ->limit(6) @@ -44,7 +43,6 @@ class ModListSection extends Component return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at']) ->withTotalDownloads() ->with(['latestVersion', 'latestVersion.sptVersion', 'users:id,name']) - ->whereHas('latestVersion') ->latest() ->limit(6) ->get(); @@ -57,7 +55,6 @@ class ModListSection extends Component return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured']) ->withTotalDownloads() ->with(['lastUpdatedVersion', 'lastUpdatedVersion.sptVersion', 'users:id,name']) - ->whereHas('lastUpdatedVersion') ->orderByDesc( ModVersion::select('updated_at') ->whereColumn('mod_id', 'mods.id') diff --git a/config/services.php b/config/services.php index 27a3617..beca5d4 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,9 @@ return [ ], ], + 'gitea' => [ + 'domain' => env('GITEA_DOMAIN', ''), + 'token' => env('GITEA_TOKEN', ''), + ], + ]; diff --git a/database/factories/ModDependencyFactory.php b/database/factories/ModDependencyFactory.php index 3c77d8d..012f5f8 100644 --- a/database/factories/ModDependencyFactory.php +++ b/database/factories/ModDependencyFactory.php @@ -17,9 +17,19 @@ class ModDependencyFactory extends Factory return [ 'mod_version_id' => ModVersion::factory(), 'dependency_mod_id' => Mod::factory(), - 'version_constraint' => '^'.$this->faker->numerify('#.#.#'), + 'version_constraint' => fake()->numerify($this->generateVersionConstraint()), 'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), 'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), ]; } + + /** + * This method generates a random version constraint from a predefined set of options. + */ + private function generateVersionConstraint(): string + { + $versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#', '>=1.2.# <2.#.#']; + + return $versionConstraints[array_rand($versionConstraints)]; + } } diff --git a/database/factories/ModVersionFactory.php b/database/factories/ModVersionFactory.php index f987552..e9a78ab 100644 --- a/database/factories/ModVersionFactory.php +++ b/database/factories/ModVersionFactory.php @@ -14,12 +14,15 @@ class ModVersionFactory extends Factory public function definition(): array { + $constraint = fake()->numerify($this->generateVersionConstraint()); + return [ 'mod_id' => Mod::factory(), 'version' => fake()->numerify('#.#.#'), 'description' => fake()->text(), 'link' => fake()->url(), - 'spt_version_id' => SptVersion::factory(), + 'spt_version_constraint' => $constraint, + 'resolved_spt_version_id' => null, 'virus_total_link' => fake()->url(), 'downloads' => fake()->randomNumber(), 'published_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), @@ -28,6 +31,31 @@ class ModVersionFactory extends Factory ]; } + /** + * This method generates a random version constraint from a predefined set of options. + */ + private function generateVersionConstraint(): string + { + $versionConstraints = ['*', '^1.#.#', '>=2.#.#', '~1.#.#']; + + return $versionConstraints[array_rand($versionConstraints)]; + } + + /** + * Indicate that the mod version should have a resolved SPT version. + */ + public function sptVersionResolved(): static + { + $constraint = fake()->numerify('#.#.#'); + + return $this->state(fn (array $attributes) => [ + 'spt_version_constraint' => $constraint, + 'resolved_spt_version_id' => SptVersion::factory()->create([ + 'version' => $constraint, + ]), + ]); + } + /** * Indicate that the mod version should be disabled. */ diff --git a/database/factories/SptVersionFactory.php b/database/factories/SptVersionFactory.php index a891069..5adf33a 100644 --- a/database/factories/SptVersionFactory.php +++ b/database/factories/SptVersionFactory.php @@ -13,8 +13,9 @@ class SptVersionFactory extends Factory public function definition(): array { return [ - 'version' => $this->faker->numerify('SPT 1.#.#'), + 'version' => $this->faker->numerify('#.#.#'), 'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']), + 'link' => $this->faker->url, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]; diff --git a/database/migrations/2024_05_15_023430_create_spt_versions_table.php b/database/migrations/2024_05_15_023430_create_spt_versions_table.php index 221b2ad..88cb14d 100644 --- a/database/migrations/2024_05_15_023430_create_spt_versions_table.php +++ b/database/migrations/2024_05_15_023430_create_spt_versions_table.php @@ -15,6 +15,7 @@ return new class extends Migration ->default(null) ->unique(); $table->string('version'); + $table->string('link'); $table->string('color_class'); $table->softDeletes(); $table->timestamps(); diff --git a/database/migrations/2024_05_15_023705_create_mod_versions_table.php b/database/migrations/2024_05_15_023705_create_mod_versions_table.php index b9929b5..dd92675 100644 --- a/database/migrations/2024_05_15_023705_create_mod_versions_table.php +++ b/database/migrations/2024_05_15_023705_create_mod_versions_table.php @@ -1,7 +1,6 @@ string('version'); $table->longText('description'); $table->string('link'); - $table->foreignIdFor(SptVersion::class) + $table->string('spt_version_constraint'); + $table->foreignId('resolved_spt_version_id') ->nullable() - ->default(null) ->constrained('spt_versions') ->nullOnDelete() ->cascadeOnUpdate(); diff --git a/database/migrations/2024_07_25_161219_create_mod_dependencies.php b/database/migrations/2024_07_25_161219_create_mod_dependencies.php index 9e81446..482e867 100644 --- a/database/migrations/2024_07_25_161219_create_mod_dependencies.php +++ b/database/migrations/2024_07_25_161219_create_mod_dependencies.php @@ -18,7 +18,7 @@ return new class extends Migration ->constrained('mods') ->cascadeOnDelete() ->cascadeOnUpdate(); - $table->string('version_constraint'); // e.g., ^1.0.1 + $table->string('version_constraint'); $table->foreignId('resolved_version_id') ->nullable() ->constrained('mod_versions') diff --git a/database/migrations/2024_08_21_134027_create_failed_jobs_table.php b/database/migrations/2024_08_21_134027_create_failed_jobs_table.php new file mode 100644 index 0000000..249da81 --- /dev/null +++ b/database/migrations/2024_08_21_134027_create_failed_jobs_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index da050de..1cfedfb 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use App\Exceptions\CircularDependencyException; use App\Models\License; use App\Models\Mod; use App\Models\ModDependency; @@ -20,7 +19,7 @@ class DatabaseSeeder extends Seeder public function run(): void { // Create a few SPT versions. - $spt_versions = SptVersion::factory(10)->create(); + $spt_versions = SptVersion::factory(30)->create(); // Create some code licenses. $licenses = License::factory(10)->create(); @@ -39,40 +38,26 @@ class DatabaseSeeder extends Seeder // Add 100 users. $users = User::factory(100)->create(); - // Add 200 mods, assigning them to the users we just created. + // Add 300 mods, assigning them to the users we just created. $allUsers = $users->merge([$administrator, $moderator]); - $mods = Mod::factory(200)->recycle([$licenses])->create(); + $mods = Mod::factory(300)->recycle([$licenses])->create(); foreach ($mods as $mod) { $userIds = $allUsers->random(rand(1, 3))->pluck('id')->toArray(); $mod->users()->attach($userIds); } - // Add 1000 mod versions, assigning them to the mods we just created. - $modVersions = ModVersion::factory(1000)->recycle([$mods, $spt_versions])->create(); + // Add 3000 mod versions, assigning them to the mods we just created. + $modVersions = ModVersion::factory(3000)->recycle([$mods, $spt_versions])->create(); // Add ModDependencies to a subset of ModVersions. foreach ($modVersions as $modVersion) { $hasDependencies = rand(0, 100) < 30; // 30% chance to have dependencies if ($hasDependencies) { - $numDependencies = rand(1, 3); // 1 to 3 dependencies - $dependencyMods = $mods->random($numDependencies); + $dependencyMods = $mods->random(rand(1, 3)); // 1 to 3 dependencies foreach ($dependencyMods as $dependencyMod) { - try { - ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create([ - 'version_constraint' => $this->generateVersionConstraint(), - ]); - } catch (CircularDependencyException $e) { - continue; - } + ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create(); } } } } - - private function generateVersionConstraint(): string - { - $versionConstraints = ['*', '^1.0.0', '>=2.0.0', '~1.1.0', '>=1.2.0 <2.0.0']; - - return $versionConstraints[array_rand($versionConstraints)]; - } } diff --git a/package-lock.json b/package-lock.json index 277063c..ec4e89a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "forge", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/phpstan.neon b/phpstan.neon index e3d130b..9cc52bf 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,9 +1,13 @@ includes: - ./vendor/larastan/larastan/extension.neon parameters: + level: 6 paths: - - app/ + - app + - bootstrap + - config + - database + - lang + - routes excludePaths: - analyseAndScan: - - tests/ - level: 4 + - tests/**/* diff --git a/resources/views/components/mod-list-section-partial.blade.php b/resources/views/components/mod-list-section-partial.blade.php index 44e3e11..efbbc7a 100644 --- a/resources/views/components/mod-list-section-partial.blade.php +++ b/resources/views/components/mod-list-section-partial.blade.php @@ -1,4 +1,4 @@ -@props(['mods, versionScope, title']) +@props(['mods', 'versionScope', 'title'])
{{-- diff --git a/resources/views/livewire/mod/index.blade.php b/resources/views/livewire/mod/index.blade.php index da1e308..1cede53 100644 --- a/resources/views/livewire/mod/index.blade.php +++ b/resources/views/livewire/mod/index.blade.php @@ -156,7 +156,7 @@ @endforeach
@else -
+

{{ __('There were no mods found with those filters applied. ') }}

diff --git a/routes/console.php b/routes/console.php index 511f247..c11c5a6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,10 @@ hourly(); +Schedule::command(ImportHubCommand::class)->hourly(); +Schedule::command(ResolveVersionsCommand::class)->hourlyAt(30); Schedule::command('horizon:snapshot')->everyFiveMinutes(); diff --git a/tests/Feature/ModDependencyTest.php b/tests/Feature/ModDependencyTest.php index 50ea28d..a6f1643 100644 --- a/tests/Feature/ModDependencyTest.php +++ b/tests/Feature/ModDependencyTest.php @@ -13,13 +13,13 @@ it('resolves mod version dependency when mod version is created', function () { $modB = Mod::factory()->create(['name' => 'Mod B']); // Create versions for Mod B - ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); - ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); - ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); - ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + ModVersion::factory()->recycle($modB)->create(['version' => '1.0.0']); + ModVersion::factory()->recycle($modB)->create(['version' => '1.1.0']); + ModVersion::factory()->recycle($modB)->create(['version' => '1.1.1']); + ModVersion::factory()->recycle($modB)->create(['version' => '2.0.0']); // Create versions for Mod A that depends on Mod B - $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); + $modAv1 = ModVersion::factory()->recycle($modA)->create(['version' => '1.0.0']); $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '^1.0.0', ]); @@ -74,7 +74,7 @@ it('resolves mod version dependency when mod version is deleted', function () { $modDependency->refresh(); expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); - // Update the mod B version + // Delete the mod B version $modBv3->delete(); $modDependency->refresh(); @@ -155,6 +155,34 @@ it('resolves mod version dependency with complex semantic version constraint', f expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); }); +it('resolves previously unresolved mod version dependency after semantic version constraint is updated', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // Create versions for Mod B + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.1']); + + // Create version for Mod A that depends on Mod B, but no version satisfies the constraint. + $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ + 'version_constraint' => '^3.0.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolved_version_id)->toBeNull(); + + // Update the dependency version constraint + $modDependency->update(['version_constraint' => '^2.0.0']); + + $modDependency->refresh(); + expect($modDependency->resolved_version_id)->not->toBeNull(); + expect($modDependency->resolvedVersion->version)->toBe('2.0.1'); +}); + it('resolves null when no mod versions are available', function () { $modA = Mod::factory()->create(['name' => 'Mod A']); $modB = Mod::factory()->create(['name' => 'Mod B']); diff --git a/tests/Feature/ModTest.php b/tests/Feature/ModTest.php index a76dff6..3365414 100644 --- a/tests/Feature/ModTest.php +++ b/tests/Feature/ModTest.php @@ -6,11 +6,28 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); -it('shows the latest version on the mod detail page', function () { +it('can retrieve all unresolved versions', function () { // Create a mod instance $mod = Mod::factory()->create(); + ModVersion::factory(5)->recycle($mod)->create(); - // Create 5 mod versions with specified versions + ModVersion::all()->each(function (ModVersion $modVersion) { + $modVersion->resolved_spt_version_id = null; + $modVersion->saveQuietly(); + }); + + $unresolvedMix = $mod->versions(resolvedOnly: false); + + $unresolvedMix->each(function (ModVersion $modVersion) { + expect($modVersion)->toBeInstanceOf(ModVersion::class) + ->and($modVersion->resolved_spt_version_id)->toBeNull(); + }); + + expect($unresolvedMix->count())->toBe(5) + ->and($mod->versions->count())->toBe(0); +}); + +it('shows the latest version on the mod detail page', function () { $versions = [ '1.0.0', '1.1.0', @@ -18,21 +35,16 @@ it('shows the latest version on the mod detail page', function () { '2.0.0', '2.1.0', ]; - - // get the highest version in the array $latestVersion = max($versions); + $mod = Mod::factory()->create(); foreach ($versions as $version) { - ModVersion::factory()->create([ - 'mod_id' => $mod->id, - 'version' => $version, - ]); + ModVersion::factory()->sptVersionResolved()->recycle($mod)->create(['version' => $version]); } - // Make a request to the mod's detail URL $response = $this->get($mod->detailUrl()); - $this->assertEquals('2.1.0', $latestVersion); + expect($latestVersion)->toBe('2.1.0'); // Assert the latest version is next to the mod's name $response->assertSeeInOrder(explode(' ', "$mod->name $latestVersion")); diff --git a/tests/Feature/ModVersionTest.php b/tests/Feature/ModVersionTest.php index be3cd91..dd4e91e 100644 --- a/tests/Feature/ModVersionTest.php +++ b/tests/Feature/ModVersionTest.php @@ -1,11 +1,81 @@ create(['version' => '1.0.0']); + SptVersion::factory()->create(['version' => '1.1.0']); + SptVersion::factory()->create(['version' => '1.1.1']); + SptVersion::factory()->create(['version' => '1.2.0']); + + $modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']); + + $modVersion->refresh(); + + expect($modVersion->resolved_spt_version_id)->not->toBeNull(); + expect($modVersion->sptVersion->version)->toBe('1.1.1'); +}); + +it('resolves spt version when constraint is updated', function () { + SptVersion::factory()->create(['version' => '1.0.0']); + SptVersion::factory()->create(['version' => '1.1.0']); + SptVersion::factory()->create(['version' => '1.1.1']); + SptVersion::factory()->create(['version' => '1.2.0']); + + $modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']); + + expect($modVersion->resolved_spt_version_id)->not->toBeNull(); + expect($modVersion->sptVersion->version)->toBe('1.1.1'); + + $modVersion->spt_version_constraint = '~1.2.0'; + $modVersion->save(); + + $modVersion->refresh(); + + expect($modVersion->resolved_spt_version_id)->not->toBeNull(); + expect($modVersion->sptVersion->version)->toBe('1.2.0'); +}); + +it('resolves spt version when spt version is created', function () { + SptVersion::factory()->create(['version' => '1.0.0']); + SptVersion::factory()->create(['version' => '1.1.0']); + SptVersion::factory()->create(['version' => '1.1.1']); + SptVersion::factory()->create(['version' => '1.2.0']); + + $modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']); + + expect($modVersion->resolved_spt_version_id)->not->toBeNull(); + expect($modVersion->sptVersion->version)->toBe('1.1.1'); + + SptVersion::factory()->create(['version' => '1.1.2']); + + $modVersion->refresh(); + + expect($modVersion->sptVersion->version)->toBe('1.1.2'); +}); + +it('resolves spt version when spt version is deleted', function () { + SptVersion::factory()->create(['version' => '1.0.0']); + SptVersion::factory()->create(['version' => '1.1.0']); + SptVersion::factory()->create(['version' => '1.1.1']); + $sptVersion = SptVersion::factory()->create(['version' => '1.1.2']); + + $modVersion = ModVersion::factory()->create(['spt_version_constraint' => '~1.1.0']); + + expect($modVersion->resolved_spt_version_id)->not->toBeNull(); + expect($modVersion->sptVersion->version)->toBe('1.1.2'); + + $sptVersion->delete(); + $modVersion->refresh(); + + expect($modVersion->sptVersion->version)->toBe('1.1.1'); +}); + it('includes only published mod versions', function () { $publishedMod = ModVersion::factory()->create([ 'published_at' => Carbon::now()->subDay(),