diff --git a/minecraft-modrinth/README.md b/minecraft-modrinth/README.md index d72a274b..bd9ba97e 100644 --- a/minecraft-modrinth/README.md +++ b/minecraft-modrinth/README.md @@ -1,15 +1,41 @@ # Minecraft Modrinth (by Boy132) -Easily download and install Minecraft mods and plugins directly from Modrinth within the server panel. +Easily download, update, and manage Minecraft mods and plugins directly from Modrinth within the server panel. ## Setup -Add `modrinth_mods` or `modrinth_plugins` to the _features_ of your egg to enable the mod/plugins page. +Add `modrinth_mods` or `modrinth_plugins` to the _features_ of your egg to enable the mod/plugins page. Also make sure your egg has the `minecraft` _tag_ and a _tag_ for the [mod loader](https://github.com/pelican-dev/plugins/blob/main/minecraft-modrinth/src/Enums/MinecraftLoader.php#L10-L16). (e.g. `paper` or `neoforge`) ## Features -- Browse and search Modrinth's extensive mod library -- Download mods and plugins directly to your server -- Automatic version compatibility checking -- Seamless installation to the correct server directory +- **Browse and Search**: Access Modrinth's extensive mod library with search and pagination +- **Smart Installation**: One-click install with automatic latest version selection +- **Status Tracking**: See which mods are installed directly in the Modrinth list +- **Update Detection**: Automatic detection of available updates with one-click upgrade +- **Easy Uninstall**: Remove mods/plugins with confirmation and automatic file cleanup +- **Metadata Management**: Tracks installed versions, filenames, and installation dates +- **Version Compatibility**: Automatic filtering by Minecraft version and mod loader +- **Seamless Installation**: Downloads to the correct server directory (mods/ or plugins/) +- **Multilingual**: Supports English and German translations + +## How It Works + +### Installing Mods/Plugins +1. Browse or search for mods in the Modrinth list +2. Click the **Install** button (download icon) +3. The latest compatible version is automatically downloaded and tracked + +### Managing Installed Mods +- **Installed** (green check): Mod is installed and up-to-date +- **Update** (orange refresh): Newer version available - click to upgrade +- **Uninstall** (red trash): Remove mod from server + +### Metadata Tracking +The plugin maintains a `.modrinth-metadata.json` file in your mods/plugins folder that tracks: +- Project ID and name +- Installed version ID and number +- Filename +- Installation date + +This enables accurate update detection and prevents duplicate installations diff --git a/minecraft-modrinth/lang/de/strings.php b/minecraft-modrinth/lang/de/strings.php index a6474b8f..b41c5bc8 100644 --- a/minecraft-modrinth/lang/de/strings.php +++ b/minecraft-modrinth/lang/de/strings.php @@ -16,6 +16,9 @@ 'loader' => 'Loader', 'installed' => 'Installiert :type', 'unknown' => 'Unbekannt', + 'view_all' => 'Alle', + 'view_installed' => 'Installiert', + 'mod_unavailable' => 'Dieser Mod/Plugin ist auf Modrinth nicht mehr verfügbar', ], 'table' => [ @@ -36,11 +39,30 @@ ], 'actions' => [ - 'download' => 'Herunterladen', + 'install' => 'Installieren', + 'installed' => 'Installiert', + 'update' => 'Aktualisieren', + 'uninstall' => 'Deinstallieren', + ], + + 'modals' => [ + 'update_heading' => 'Mod/Plugin aktualisieren', + 'update_description' => 'Dies ersetzt Version :old_version durch Version :new_version. Die alte Datei wird gelöscht.', + 'uninstall_heading' => 'Mod/Plugin deinstallieren', + 'uninstall_description' => 'Möchten Sie :name wirklich deinstallieren? Dies wird die Datei dauerhaft von Ihrem Server löschen.', ], 'notifications' => [ - 'download_started' => 'Download gestartet', - 'download_failed' => 'Download konnte nicht gestartet werden', + 'install_success' => 'Installation abgeschlossen', + 'install_success_body' => ':name Version :version erfolgreich installiert', + 'install_failed' => 'Installation fehlgeschlagen', + 'install_failed_body' => 'Bei der Installation ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.', + 'update_success' => 'Aktualisierung abgeschlossen', + 'update_success_body' => 'Erfolgreich auf Version :version aktualisiert', + 'update_failed' => 'Aktualisierung fehlgeschlagen', + 'update_failed_body' => 'Bei der Aktualisierung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.', + 'uninstall_success' => 'Deinstallation abgeschlossen', + 'uninstall_failed' => 'Deinstallation fehlgeschlagen', + 'uninstall_failed_body' => 'Bei der Deinstallation ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.', ], ]; diff --git a/minecraft-modrinth/lang/en/strings.php b/minecraft-modrinth/lang/en/strings.php index fc3b36e4..c3425aec 100644 --- a/minecraft-modrinth/lang/en/strings.php +++ b/minecraft-modrinth/lang/en/strings.php @@ -16,6 +16,9 @@ 'loader' => 'Loader', 'installed' => 'Installed :type', 'unknown' => 'Unknown', + 'view_all' => 'All', + 'view_installed' => 'Installed', + 'mod_unavailable' => 'This mod/plugin is no longer available on Modrinth', ], 'table' => [ @@ -36,11 +39,30 @@ ], 'actions' => [ - 'download' => 'Download', + 'install' => 'Install', + 'installed' => 'Installed', + 'update' => 'Update', + 'uninstall' => 'Uninstall', + ], + + 'modals' => [ + 'update_heading' => 'Update Mod/Plugin', + 'update_description' => 'This will replace version :old_version with version :new_version. The old file will be deleted.', + 'uninstall_heading' => 'Uninstall Mod/Plugin', + 'uninstall_description' => 'Are you sure you want to uninstall :name? This will permanently delete the file from your server.', ], 'notifications' => [ - 'download_started' => 'Download started', - 'download_failed' => 'Download could not be started', + 'install_success' => 'Installation completed', + 'install_success_body' => 'Successfully installed :name version :version', + 'install_failed' => 'Installation failed', + 'install_failed_body' => 'An error occurred during installation. Please try again or contact support if the issue persists.', + 'update_success' => 'Update completed', + 'update_success_body' => 'Successfully updated to version :version', + 'update_failed' => 'Update failed', + 'update_failed_body' => 'An error occurred during the update. Please try again or contact support if the issue persists.', + 'uninstall_success' => 'Uninstall completed', + 'uninstall_failed' => 'Uninstall failed', + 'uninstall_failed_body' => 'An error occurred during uninstallation. Please try again or contact support if the issue persists.', ], ]; diff --git a/minecraft-modrinth/plugin.json b/minecraft-modrinth/plugin.json index fd553876..2302f9cc 100644 --- a/minecraft-modrinth/plugin.json +++ b/minecraft-modrinth/plugin.json @@ -2,8 +2,8 @@ "id": "minecraft-modrinth", "name": "Minecraft Modrinth", "author": "Boy132", - "version": "1.0.0", - "description": "Easily download minecraft mods & plugins from modrinth", + "version": "1.1.0", + "description": "Easily download, update, and manage minecraft mods & plugins from modrinth", "category": "plugin", "url": "https://github.com/pelican-dev/plugins/tree/main/minecraft-modrinth", "update_url": null, diff --git a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php index a1b9e961..0acaa661 100644 --- a/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php +++ b/minecraft-modrinth/src/Filament/Server/Pages/MinecraftModrinthProjectPage.php @@ -17,7 +17,6 @@ use Filament\Pages\Page; use Filament\Schemas\Components\EmbeddedTable; use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; @@ -26,18 +25,39 @@ use Filament\Tables\Table; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Http; class MinecraftModrinthProjectPage extends Page implements HasTable { use BlockAccessInConflict; use InteractsWithTable; + public const VIEW_ALL = 'all'; + + public const VIEW_INSTALLED = 'installed'; + + /** @var array|null */ + protected ?array $installedModsMetadata = null; + + /** @var array> Cache for version data by project_id */ + protected array $versionsCache = []; + + public string $activeView = self::VIEW_ALL; + protected static string|\BackedEnum|null $navigationIcon = 'tabler-packages'; protected static ?string $slug = 'modrinth'; protected static ?int $navigationSort = 30; + /** @return array */ + protected function queryString(): array + { + return [ + 'activeView' => ['as' => 'view', 'except' => self::VIEW_ALL], + ]; + } + public static function canAccess(): bool { /** @var Server $server */ @@ -51,7 +71,9 @@ public static function getNavigationLabel(): string /** @var Server $server */ $server = Filament::getTenant(); - return ModrinthProjectType::fromServer($server)->getLabel(); + $type = ModrinthProjectType::fromServer($server); + + return $type?->getLabel() ?? 'Modrinth'; } public static function getModelLabel(): string @@ -69,6 +91,81 @@ public function getTitle(): string return static::getNavigationLabel(); } + /** @return array */ + protected function getInstalledModsMetadata(): array + { + if ($this->installedModsMetadata === null) { + /** @var Server $server */ + $server = Filament::getTenant(); + /** @var DaemonFileRepository $fileRepository */ + $fileRepository = app(DaemonFileRepository::class); + + $this->installedModsMetadata = MinecraftModrinth::getInstalledModsMetadata($server, $fileRepository); + } + + return $this->installedModsMetadata; + } + + /** @return array{project_id: string, project_slug: string, project_title: string, version_id: string, version_number: string, filename: string, installed_at: string, author?: string}|null */ + protected function getInstalledMod(string $projectId): ?array + { + $installedMods = $this->getInstalledModsMetadata(); + + foreach ($installedMods as $mod) { + if ($mod['project_id'] === $projectId) { + return $mod; + } + } + + return null; + } + + /** @return array */ + protected function getCachedVersions(string $projectId): array + { + if (!isset($this->versionsCache[$projectId])) { + /** @var Server $server */ + $server = Filament::getTenant(); + $this->versionsCache[$projectId] = MinecraftModrinth::getModrinthVersions($projectId, $server); + } + + return $this->versionsCache[$projectId]; + } + + /** + * @param array $files + * @return array{primary: bool, filename: string, url: string}|null + */ + protected function getPrimaryFile(array $files): ?array + { + foreach ($files as $file) { + if (!empty($file['primary'])) { + return $file; + } + } + + return null; + } + + /** + * @throws Exception + */ + protected function validateFilename(string $filename): string + { + if ($filename === '' || str_contains($filename, '/') || str_contains($filename, '\\')) { + throw new Exception('Invalid filename: potential path traversal detected'); + } + + return basename($filename); + } + + protected function refreshIfInInstalledView(): void + { + if ($this->activeView === self::VIEW_INSTALLED) { + $this->js('$wire.$refresh()'); + } + } + /** * @throws Exception */ @@ -79,9 +176,18 @@ public function table(Table $table): Table /** @var Server $server */ $server = Filament::getTenant(); - $response = MinecraftModrinth::getModrinthProjects($server, $page, $search); + if ($this->activeView === self::VIEW_INSTALLED) { + $installedMods = $this->getInstalledModsMetadata(); + $projects = MinecraftModrinth::getInstalledModsFromModrinth($installedMods, $page); + + $totalCount = count($installedMods); + + return new LengthAwarePaginator($projects, $totalCount, 20, $page); + } else { + $response = MinecraftModrinth::getModrinthProjects($server, $page, $search); - return new LengthAwarePaginator($response['hits'], $response['total_hits'], 20, $page); + return new LengthAwarePaginator($response['hits'], $response['total_hits'], 20, $page); + } }) ->paginated([20]) ->columns([ @@ -99,81 +205,333 @@ public function table(Table $table): Table ->toggleable(), TextColumn::make('date_modified') ->icon('tabler-calendar') - ->formatStateUsing(fn ($state) => Carbon::parse($state, 'UTC')->diffForHumans()) - ->tooltip(fn ($state) => Carbon::parse($state, 'UTC')->timezone(user()->timezone ?? 'UTC')->format($table->getDefaultDateTimeDisplayFormat())) + ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state, 'UTC')->diffForHumans() : '') + ->tooltip(fn ($state) => $state ? Carbon::parse($state, 'UTC')->timezone(user()->timezone ?? 'UTC')->format($table->getDefaultDateTimeDisplayFormat()) : '') ->toggleable(), ]) - ->recordUrl(fn (array $record) => "https://modrinth.com/{$record['project_type']}/{$record['slug']}", true) + ->recordUrl(function (array $record) { + if (!empty($record['unavailable'])) { + return null; + } + + return "https://modrinth.com/{$record['project_type']}/{$record['slug']}"; + }, true) ->recordActions([ - Action::make('download') - ->schema(function (array $record) { - $schema = []; - - /** @var Server $server */ - $server = Filament::getTenant(); - - $versions = array_slice(MinecraftModrinth::getModrinthVersions($record['project_id'], $server), 0, 10); - foreach ($versions as $versionData) { - $files = $versionData['files'] ?? []; - $primaryFile = null; - - foreach ($files as $fileData) { - if ($fileData['primary']) { - $primaryFile = $fileData; - break; + Action::make('install') + ->iconButton() + ->icon('tabler-download') + ->color('success') + ->tooltip(trans('minecraft-modrinth::strings.actions.install')) + ->visible(function (array $record) { + $installedMod = $this->getInstalledMod($record['project_id']); + + return is_null($installedMod); + }) + ->action(function (array $record, DaemonFileRepository $fileRepository) { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + $versions = MinecraftModrinth::getModrinthVersions($record['project_id'], $server); + + if (empty($versions)) { + throw new Exception('No compatible versions found'); + } + + $latestVersion = $versions[0]; + + if (!isset($latestVersion['id'], $latestVersion['version_number'], $latestVersion['files'])) { + throw new Exception('Invalid version data structure'); + } + + $primaryFile = $this->getPrimaryFile($latestVersion['files']); + + if (!$primaryFile) { + throw new Exception('No downloadable file found'); + } + + $safeFilename = $this->validateFilename($primaryFile['filename']); + + $type = ModrinthProjectType::fromServer($server); + if (!$type) { + throw new Exception('Server does not support Modrinth mods or plugins'); + } + + $fileRepository->setServer($server)->pull($primaryFile['url'], $type->getFolder()); + + $saved = MinecraftModrinth::saveModMetadata( + $server, + $fileRepository, + $record['project_id'], + $record['slug'], + $record['title'], + $latestVersion['id'], + $latestVersion['version_number'], + $safeFilename, + $record['author'] ?? null + ); + + if (!$saved) { + try { + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$type->getFolder() . '/' . $safeFilename], + ]) + ->throw(); + } catch (Exception $rollbackException) { + report($rollbackException); } + + throw new Exception('Failed to save mod metadata'); } - $schema[] = Section::make($versionData['name']) - ->description($versionData['version_number'] . ($primaryFile ? ' (' . convert_bytes_to_readable($primaryFile['size']) . ')' : ' (' . trans('minecraft-modrinth::strings.version.no_file_found') . ')')) - ->collapsed(!$versionData['featured']) - ->collapsible() - ->icon($versionData['version_type'] === 'alpha' ? 'tabler-circle-letter-a' : ($versionData['version_type'] === 'beta' ? 'tabler-circle-letter-b' : 'tabler-circle-letter-r')) - ->iconColor($versionData['version_type'] === 'alpha' ? 'danger' : ($versionData['version_type'] === 'beta' ? 'warning' : 'success')) - ->columns(3) - ->schema([ - TextEntry::make('type') - ->badge() - ->color($versionData['version_type'] === 'alpha' ? 'danger' : ($versionData['version_type'] === 'beta' ? 'warning' : 'success')) - ->state($versionData['version_type']), - TextEntry::make('downloads') - ->badge() - ->state($versionData['downloads']), - TextEntry::make('published') - ->badge() - ->state(Carbon::parse($versionData['date_published'], 'UTC')->diffForHumans()) - ->tooltip(Carbon::parse($versionData['date_published'], 'UTC')->timezone(user()->timezone ?? 'UTC')->format('M j, Y H:i:s')), - TextEntry::make('changelog') - ->columnSpanFull() - ->markdown() - ->state($versionData['changelog']), - ]) - ->headerActions([ - Action::make('download') - ->visible(!is_null($primaryFile)) - ->action(function (DaemonFileRepository $fileRepository) use ($server, $versionData, $primaryFile) { - try { - $fileRepository->setServer($server)->pull($primaryFile['url'], ModrinthProjectType::fromServer($server)->getFolder()); - - Notification::make() - ->title(trans('minecraft-modrinth::strings.notifications.download_started')) - ->body($versionData['name']) - ->success() - ->send(); - } catch (Exception $exception) { - report($exception); - - Notification::make() - ->title(trans('minecraft-modrinth::strings.notifications.download_failed')) - ->body($exception->getMessage()) - ->danger() - ->send(); - } - }), - ]); + $this->installedModsMetadata = null; + $this->versionsCache = []; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.install_success')) + ->body(trans('minecraft-modrinth::strings.notifications.install_success_body', [ + 'name' => $record['title'], + 'version' => $latestVersion['version_number'], + ])) + ->success() + ->send(); + } catch (Exception $exception) { + report($exception); + + $this->installedModsMetadata = null; + $this->versionsCache = []; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.install_failed')) + ->body(trans('minecraft-modrinth::strings.notifications.install_failed_body')) + ->danger() + ->send(); } + }), + Action::make('update') + ->iconButton() + ->icon('tabler-refresh') + ->color('warning') + ->tooltip(trans('minecraft-modrinth::strings.actions.update')) + ->visible(function (array $record) { + $installedMod = $this->getInstalledMod($record['project_id']); + + if (is_null($installedMod)) { + return false; + } + + $versions = $this->getCachedVersions($record['project_id']); + + if (empty($versions)) { + return false; + } + + return $installedMod['version_id'] !== $versions[0]['id']; + }) + ->requiresConfirmation() + ->modalHeading(fn (array $record) => trans('minecraft-modrinth::strings.modals.update_heading')) + ->modalDescription(function (array $record) { + $installedMod = $this->getInstalledMod($record['project_id']); + $versions = $this->getCachedVersions($record['project_id']); + + return trans('minecraft-modrinth::strings.modals.update_description', [ + 'old_version' => $installedMod['version_number'] ?? 'unknown', + 'new_version' => $versions[0]['version_number'] ?? 'unknown', + ]); + }) + ->action(function (array $record, DaemonFileRepository $fileRepository) { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + $installedMod = $this->getInstalledMod($record['project_id']); + + if (!$installedMod) { + throw new Exception('Mod not found in metadata'); + } + + $safeFilename = $this->validateFilename($installedMod['filename']); + + $versions = MinecraftModrinth::getModrinthVersions($record['project_id'], $server); + + if (empty($versions)) { + throw new Exception('No compatible versions found'); + } + + $latestVersion = $versions[0]; + + if (!isset($latestVersion['id'], $latestVersion['version_number'], $latestVersion['files'])) { + throw new Exception('Invalid version data structure'); + } + + $primaryFile = $this->getPrimaryFile($latestVersion['files']); + + if (!$primaryFile) { + throw new Exception('No downloadable file found'); + } + + $safeNewFilename = $this->validateFilename($primaryFile['filename']); + + $type = ModrinthProjectType::fromServer($server); + if (!$type) { + throw new Exception('Server does not support Modrinth mods or plugins'); + } + + $folder = $type->getFolder(); + + $fileRepository->setServer($server)->pull($primaryFile['url'], $folder); + + $saved = MinecraftModrinth::saveModMetadata( + $server, + $fileRepository, + $record['project_id'], + $record['slug'], + $record['title'], + $latestVersion['id'], + $latestVersion['version_number'], + $safeNewFilename, + $record['author'] ?? null + ); + + if (!$saved) { + try { + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$folder . '/' . $safeNewFilename], + ]) + ->throw(); + } catch (Exception $rollbackException) { + report($rollbackException); + } + + throw new Exception('Failed to save mod metadata'); + } + + if ($safeFilename !== $safeNewFilename) { + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$folder . '/' . $safeFilename], + ]) + ->throw(); + } - return $schema; + $this->installedModsMetadata = null; + $this->versionsCache = []; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.update_success')) + ->body(trans('minecraft-modrinth::strings.notifications.update_success_body', [ + 'version' => $latestVersion['version_number'], + ])) + ->success() + ->send(); + } catch (Exception $exception) { + report($exception); + + $this->installedModsMetadata = null; + $this->versionsCache = []; + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.update_failed')) + ->body(trans('minecraft-modrinth::strings.notifications.update_failed_body')) + ->danger() + ->send(); + } + }), + Action::make('installed') + ->iconButton() + ->icon('tabler-check') + ->color('success') + ->tooltip(trans('minecraft-modrinth::strings.actions.installed')) + ->disabled() + ->visible(function (array $record) { + $installedMod = $this->getInstalledMod($record['project_id']); + + if (is_null($installedMod)) { + return false; + } + + $versions = $this->getCachedVersions($record['project_id']); + + if (empty($versions)) { + return true; + } + + return $installedMod['version_id'] === $versions[0]['id']; + }), + Action::make('uninstall') + ->iconButton() + ->icon('tabler-trash') + ->color('danger') + ->tooltip(trans('minecraft-modrinth::strings.actions.uninstall')) + ->visible(function (array $record) { + return !is_null($this->getInstalledMod($record['project_id'])); + }) + ->requiresConfirmation() + ->modalHeading(fn (array $record) => trans('minecraft-modrinth::strings.modals.uninstall_heading')) + ->modalDescription(fn (array $record) => trans('minecraft-modrinth::strings.modals.uninstall_description', ['name' => $record['title']])) + ->action(function (array $record, DaemonFileRepository $fileRepository) { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + + $installedMod = $this->getInstalledMod($record['project_id']); + + if (!$installedMod) { + throw new Exception('Mod not found in metadata'); + } + + $safeFilename = $this->validateFilename($installedMod['filename']); + + // Remove metadata first to maintain consistency + // If file deletion fails after this, the file will exist but won't be tracked + // which is safer than having metadata pointing to a non-existent file + $metadataRemoved = MinecraftModrinth::removeModMetadata($server, $fileRepository, $record['project_id']); + + if ($metadataRemoved === false) { + throw new Exception('Failed to remove mod metadata'); + } + + $type = ModrinthProjectType::fromServer($server); + if (!$type) { + throw new Exception('Server does not support Modrinth mods or plugins'); + } + + $folder = $type->getFolder(); + + Http::daemon($server->node) + ->post("/api/servers/{$server->uuid}/files/delete", [ + 'root' => '/', + 'files' => [$folder . '/' . $safeFilename], + ]) + ->throw(); + + $this->installedModsMetadata = null; + $this->versionsCache = []; + $this->refreshIfInInstalledView(); + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.uninstall_success')) + ->body($record['title']) + ->success() + ->send(); + } catch (Exception $exception) { + report($exception); + + $this->installedModsMetadata = null; + $this->versionsCache = []; + $this->refreshIfInInstalledView(); + + Notification::make() + ->title(trans('minecraft-modrinth::strings.notifications.uninstall_failed')) + ->body(trans('minecraft-modrinth::strings.notifications.uninstall_failed_body')) + ->danger() + ->send(); + } }), ]); } @@ -183,9 +541,32 @@ protected function getHeaderActions(): array /** @var Server $server */ $server = Filament::getTenant(); - $folder = ModrinthProjectType::fromServer($server)->getFolder(); + $type = ModrinthProjectType::fromServer($server); + if (!$type) { + return []; + } + + $folder = $type->getFolder(); return [ + Action::make('view_all') + ->label(trans('minecraft-modrinth::strings.page.view_all')) + ->color($this->activeView === self::VIEW_ALL ? 'primary' : 'gray') + ->action(function () { + $this->resetTable(); + $this->activeView = self::VIEW_ALL; + $this->js('$wire.$refresh()'); + }) + ->button(), + Action::make('view_installed') + ->label(trans('minecraft-modrinth::strings.page.view_installed')) + ->color($this->activeView === self::VIEW_INSTALLED ? 'primary' : 'gray') + ->action(function () { + $this->resetTable(); + $this->activeView = self::VIEW_INSTALLED; + $this->js('$wire.$refresh()'); + }) + ->button(), Action::make('open_folder') ->label(fn () => trans('minecraft-modrinth::strings.page.open_folder', ['folder' => $folder])) ->url(fn () => ListFiles::getUrl(['path' => $folder]), true), @@ -197,6 +578,8 @@ public function content(Schema $schema): Schema /** @var Server $server */ $server = Filament::getTenant(); + $type = ModrinthProjectType::fromServer($server); + return $schema ->components([ Grid::make(3) @@ -208,10 +591,14 @@ public function content(Schema $schema): Schema ->state(fn () => MinecraftLoader::fromServer($server)?->getLabel() ?? trans('minecraft-modrinth::strings.page.unknown')) ->badge(), TextEntry::make('installed') - ->label(fn () => trans('minecraft-modrinth::strings.page.installed', ['type' => ModrinthProjectType::fromServer($server)->getLabel()])) - ->state(function (DaemonFileRepository $fileRepository) use ($server) { + ->label(fn () => trans('minecraft-modrinth::strings.page.installed', ['type' => $type?->getLabel() ?? 'Modrinth'])) + ->state(function (DaemonFileRepository $fileRepository) use ($server, $type) { try { - $files = $fileRepository->setServer($server)->getDirectory(ModrinthProjectType::fromServer($server)->getFolder()); + if (!$type) { + return trans('minecraft-modrinth::strings.page.unknown'); + } + + $files = $fileRepository->setServer($server)->getDirectory($type->getFolder()); if (isset($files['error'])) { throw new Exception($files['error']); diff --git a/minecraft-modrinth/src/Services/MinecraftModrinthService.php b/minecraft-modrinth/src/Services/MinecraftModrinthService.php index 40fbb7ae..ef6bf56c 100644 --- a/minecraft-modrinth/src/Services/MinecraftModrinthService.php +++ b/minecraft-modrinth/src/Services/MinecraftModrinthService.php @@ -3,6 +3,7 @@ namespace Boy132\MinecraftModrinth\Services; use App\Models\Server; +use App\Repositories\Daemon\DaemonFileRepository; use Boy132\MinecraftModrinth\Enums\MinecraftLoader; use Boy132\MinecraftModrinth\Enums\ModrinthProjectType; use Exception; @@ -69,6 +70,97 @@ public function getModrinthProjects(Server $server, int $page = 1, ?string $sear }); } + /** + * @param array $installedMods + * @return array> + */ + public function getInstalledModsFromModrinth(array $installedMods, int $page = 1): array + { + if (empty($installedMods)) { + return []; + } + + $projectIds = collect($installedMods)->pluck('project_id')->unique()->values()->all(); + + $perPage = 20; + $offset = ($page - 1) * $perPage; + $pageIds = array_slice($projectIds, $offset, $perPage); + + if (empty($pageIds)) { + return []; + } + + $idsParam = '["' . implode('","', $pageIds) . '"]'; + $key = 'modrinth_bulk:' . md5($idsParam); + + $modrinthProjects = cache()->remember($key, now()->addMinutes(30), function () use ($idsParam) { + try { + return Http::asJson() + ->timeout(10) + ->connectTimeout(5) + ->throw() + ->get('https://api.modrinth.com/v2/projects', [ + 'ids' => $idsParam, + ]) + ->json(); + } catch (Exception $exception) { + report($exception); + + return []; + } + }); + + $modrinthMap = []; + foreach ($modrinthProjects as $project) { + if (isset($project['id'])) { + $modrinthMap[$project['id']] = $project; + } + } + + $results = []; + foreach ($pageIds as $projectId) { + $installedMod = null; + foreach ($installedMods as $mod) { + if ($mod['project_id'] === $projectId) { + $installedMod = $mod; + break; + } + } + + if (!$installedMod) { + continue; + } + + if (isset($modrinthMap[$projectId])) { + $project = $modrinthMap[$projectId]; + $project['project_id'] = $project['id']; + if (isset($project['updated']) && !isset($project['date_modified'])) { + $project['date_modified'] = $project['updated']; + } + // Use stored author from metadata if available, since bulk API doesn't include it + if (isset($installedMod['author']) && !isset($project['author'])) { + $project['author'] = $installedMod['author']; + } + $results[] = $project; + } else { + $results[] = [ + 'project_id' => $installedMod['project_id'], + 'slug' => $installedMod['project_slug'], + 'title' => $installedMod['project_title'], + 'description' => trans('minecraft-modrinth::strings.page.mod_unavailable'), + 'icon_url' => null, + 'author' => $installedMod['author'] ?? '', + 'downloads' => 0, + 'date_modified' => $installedMod['installed_at'], + 'project_type' => '', + 'unavailable' => true, + ]; + } + } + + return $results; + } + /** @return array */ public function getModrinthVersions(string $projectId, Server $server): array { @@ -87,12 +179,20 @@ public function getModrinthVersions(string $projectId, Server $server): array return cache()->remember("modrinth_versions:$projectId:$minecraftVersion:$minecraftLoader", now()->addMinutes(30), function () use ($projectId, $data) { try { - return Http::asJson() + $versions = Http::asJson() ->timeout(5) ->connectTimeout(5) ->throw() ->get("https://api.modrinth.com/v2/project/$projectId/version", $data) ->json(); + + if (!empty($versions) && is_array($versions) && isset($versions[0]['date_published'])) { + usort($versions, function ($a, $b) { + return strcmp($b['date_published'] ?? '', $a['date_published'] ?? ''); + }); + } + + return $versions; } catch (Exception $exception) { report($exception); @@ -100,4 +200,187 @@ public function getModrinthVersions(string $projectId, Server $server): array } }); } + + /** + * @throws Exception + */ + protected function getMetadataFilePath(Server $server): string + { + $type = ModrinthProjectType::fromServer($server); + + if (!$type) { + throw new Exception("Server {$server->id} does not support Modrinth mods or plugins"); + } + + return $type->getFolder() . '/.modrinth-metadata.json'; + } + + /** @return array */ + public function getInstalledModsMetadata(Server $server, DaemonFileRepository $fileRepository): array + { + try { + $metadataPath = $this->getMetadataFilePath($server); + $content = $fileRepository->setServer($server)->getContent($metadataPath); + $metadata = json_decode($content, true); + + if (!is_array($metadata) || !isset($metadata['installed_mods']) || !is_array($metadata['installed_mods'])) { + return []; + } + + $validInstalledMods = []; + $requiredKeys = [ + 'project_id', + 'project_slug', + 'project_title', + 'version_id', + 'version_number', + 'filename', + 'installed_at', + ]; + + $requiredKeysFlipped = array_flip($requiredKeys); + + foreach ($metadata['installed_mods'] as $entry) { + if (!is_array($entry)) { + continue; + } + + $missingKeys = array_diff_key($requiredKeysFlipped, $entry); + if (empty($missingKeys)) { + $validInstalledMods[] = $entry; + } + } + + return $validInstalledMods; + } catch (Exception $exception) { + return []; + } + } + + public function saveModMetadata( + Server $server, + DaemonFileRepository $fileRepository, + string $projectId, + string $projectSlug, + string $projectTitle, + string $versionId, + string $versionNumber, + string $filename, + ?string $author = null + ): bool { + try { + $metadata = [ + 'installed_mods' => $this->getInstalledModsMetadata($server, $fileRepository), + ]; + + $metadata['installed_mods'] = collect($metadata['installed_mods']) + ->filter(fn ($mod) => $mod['project_id'] !== $projectId) + ->values() + ->toArray(); + + $modEntry = [ + 'project_id' => $projectId, + 'project_slug' => $projectSlug, + 'project_title' => $projectTitle, + 'version_id' => $versionId, + 'version_number' => $versionNumber, + 'filename' => $filename, + 'installed_at' => now()->toIso8601String(), + ]; + + if ($author !== null) { + $modEntry['author'] = $author; + } + + $metadata['installed_mods'][] = $modEntry; + + $metadataPath = $this->getMetadataFilePath($server); + $response = $fileRepository->setServer($server)->putContent( + $metadataPath, + json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + + if ($response->failed()) { + return false; + } + + return true; + } catch (Exception $exception) { + report($exception); + + return false; + } + } + + public function removeModMetadata(Server $server, DaemonFileRepository $fileRepository, string $projectId): bool + { + try { + $metadata = [ + 'installed_mods' => $this->getInstalledModsMetadata($server, $fileRepository), + ]; + + $metadata['installed_mods'] = collect($metadata['installed_mods']) + ->filter(fn ($mod) => $mod['project_id'] !== $projectId) + ->values() + ->toArray(); + + $metadataPath = $this->getMetadataFilePath($server); + $response = $fileRepository->setServer($server)->putContent( + $metadataPath, + json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + + if ($response->failed()) { + return false; + } + + return true; + } catch (Exception $exception) { + report($exception); + + return false; + } + } + + /** @return array{project_id: string, project_slug: string, project_title: string, version_id: string, version_number: string, filename: string, installed_at: string, author?: string}|null */ + public function getInstalledMod(Server $server, DaemonFileRepository $fileRepository, string $projectId): ?array + { + $installedMods = $this->getInstalledModsMetadata($server, $fileRepository); + + foreach ($installedMods as $mod) { + if ($mod['project_id'] === $projectId) { + return $mod; + } + } + + return null; + } + + /** + * @param array{version_id: string, version_number: string} $installedMod + * @param array $availableVersions + */ + public function isUpdateAvailable(array $installedMod, array $availableVersions): bool + { + if (empty($availableVersions)) { + return false; + } + + $latestVersion = $availableVersions[0]; + + return $installedMod['version_id'] !== $latestVersion['id']; + } + + /** + * @return array + */ + public function getInstalledMods(Server $server, DaemonFileRepository $fileRepository): array + { + $metadata = $this->getInstalledModsMetadata($server, $fileRepository); + + return collect($metadata) + ->pluck('filename') + ->map(fn ($name) => strtolower($name)) + ->toArray(); + } }