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..e952b155 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,31 @@
],
'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öchtest du :name wirklich deinstallieren? Dies wird die Datei dauerhaft von deinem 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 versuche es erneut oder wende dich 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 versuche es erneut oder wende dich an den Support, wenn das Problem weiterhin besteht.',
+ 'uninstall_success' => 'Deinstallation abgeschlossen',
+ 'uninstall_success_body' => ':name erfolgreich deinstalliert',
+ 'uninstall_failed' => 'Deinstallation fehlgeschlagen',
+ 'uninstall_failed_body' => 'Bei der Deinstallation ist ein Fehler aufgetreten. Bitte versuche es erneut oder wende dich 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..e3c2211f 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,31 @@
],
'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_success_body' => 'Successfully uninstalled :name',
+ '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..b0aed89d 100644
--- a/minecraft-modrinth/plugin.json
+++ b/minecraft-modrinth/plugin.json
@@ -3,7 +3,7 @@
"name": "Minecraft Modrinth",
"author": "Boy132",
"version": "1.0.0",
- "description": "Easily download minecraft mods & plugins from modrinth",
+ "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/resources/views/components/tabs.blade.php b/minecraft-modrinth/resources/views/components/tabs.blade.php
new file mode 100644
index 00000000..03c5e776
--- /dev/null
+++ b/minecraft-modrinth/resources/views/components/tabs.blade.php
@@ -0,0 +1,17 @@
+
+
+
+ {{ trans('minecraft-modrinth::strings.page.view_all') }}
+
+
+
+ {{ trans('minecraft-modrinth::strings.page.view_installed') }}
+
+
+
diff --git a/minecraft-modrinth/resources/views/modrinth-project-page.blade.php b/minecraft-modrinth/resources/views/modrinth-project-page.blade.php
new file mode 100644
index 00000000..a7417332
--- /dev/null
+++ b/minecraft-modrinth/resources/views/modrinth-project-page.blade.php
@@ -0,0 +1,3 @@
+
+ {{ $this->content }}
+
diff --git a/minecraft-modrinth/src/Filament/Components/TabsComponent.php b/minecraft-modrinth/src/Filament/Components/TabsComponent.php
new file mode 100644
index 00000000..93057452
--- /dev/null
+++ b/minecraft-modrinth/src/Filament/Components/TabsComponent.php
@@ -0,0 +1,15 @@
+|null */
+ protected ?array $installedModsMetadata = null;
+
+ /** @var array> Cache for version data by project_id */
+ protected array $versionsCache = [];
+
protected static string|\BackedEnum|null $navigationIcon = 'tabler-packages';
protected static ?string $slug = 'modrinth';
@@ -51,7 +68,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 +88,106 @@ public function getTitle(): string
return static::getNavigationLabel();
}
+ /**
+ * Normalizes a tab value to ensure it's in the allowed list.
+ * Returns the default tab if the value is invalid.
+ */
+ private function normalizeTab(string $tab): string
+ {
+ return in_array($tab, self::ALLOWED_TABS, true) ? $tab : self::DEFAULT_TAB;
+ }
+
+ public function mount(): void
+ {
+ $this->activeTab = $this->normalizeTab($this->activeTab);
+ }
+
+ public function setActiveTab(string $tab): void
+ {
+ $this->activeTab = $this->normalizeTab($tab);
+ }
+
+ public function updatedActiveTab(): void
+ {
+ $this->activeTab = $this->normalizeTab($this->activeTab);
+ $this->resetTable();
+ }
+
+ /** @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->activeTab === 'installed') {
+ $this->js('$wire.$refresh()');
+ }
+ }
+
/**
* @throws Exception
*/
@@ -79,9 +198,18 @@ public function table(Table $table): Table
/** @var Server $server */
$server = Filament::getTenant();
- $response = MinecraftModrinth::getModrinthProjects($server, $page, $search);
+ if ($this->activeTab === 'installed') {
+ $installedMods = $this->getInstalledModsMetadata();
+ $projects = MinecraftModrinth::getInstalledModsFromModrinth($installedMods, $page);
+
+ $totalCount = count($installedMods);
- return new LengthAwarePaginator($response['hits'], $response['total_hits'], 20, $page);
+ return new LengthAwarePaginator($projects, $totalCount, 20, $page);
+ } else {
+ $response = MinecraftModrinth::getModrinthProjects($server, $page, $search);
+
+ return new LengthAwarePaginator($response['hits'], $response['total_hits'], 20, $page);
+ }
})
->paginated([20])
->columns([
@@ -99,81 +227,335 @@ 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();
+ }
+
+ $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 $schema;
+ 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(trans('minecraft-modrinth::strings.notifications.uninstall_success_body', [
+ 'name' => $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,7 +565,12 @@ 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('open_folder')
@@ -197,6 +584,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 +597,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']);
@@ -228,6 +621,7 @@ public function content(Schema $schema): Schema
})
->badge(),
]),
+ TabsComponent::make(),
EmbeddedTable::make(),
]);
}
diff --git a/minecraft-modrinth/src/Providers/MinecraftModrinthPluginProvider.php b/minecraft-modrinth/src/Providers/MinecraftModrinthPluginProvider.php
new file mode 100644
index 00000000..f8d96f71
--- /dev/null
+++ b/minecraft-modrinth/src/Providers/MinecraftModrinthPluginProvider.php
@@ -0,0 +1,16 @@
+loadViewsFrom(
+ plugin_path('minecraft-modrinth', 'resources/views'),
+ 'minecraft-modrinth'
+ );
+ }
+}
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();
+ }
}