Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions minecraft-modrinth/lang/de/strings.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
'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_partial' => 'Deinstallation unvollständig',
'uninstall_partial_body' => 'Die Datei von :name wurde gelöscht, konnte aber nicht aus der Liste der installierten Mods/Plugins entfernt werden. Sie wird eventuell weiterhin als installiert angezeigt.',
'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.',
],
Expand Down
2 changes: 2 additions & 0 deletions minecraft-modrinth/lang/en/strings.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
'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_partial' => 'Uninstall incomplete',
'uninstall_partial_body' => 'The file for :name was deleted, but it could not be removed from the installed list. It may still appear as installed.',
'uninstall_failed' => 'Uninstall failed',
'uninstall_failed_body' => 'An error occurred during uninstallation. Please try again or contact support if the issue persists.',
],
Expand Down
5 changes: 5 additions & 0 deletions minecraft-modrinth/src/Facades/MinecraftModrinth.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@
* @method static array{hits: array<int, array<string, mixed>>, total_hits: int} getProjects(Server $server, ModrinthProjectType $modrinthProjectType, int $page = 1, ?string $search = null)
* @method static array<int, array<string, mixed>> getInstalledModsFromModrinth(array<int, InstalledModMetadata> $installedMods, int $page = 1)
* @method static array<int, ModrinthVersion> getProjectVersions(string $projectId, Server $server)
* @method static array<string, array<int, ModrinthVersion>> getProjectVersionsBulk(array<int, string> $projectIds, Server $server)
* @method static array<int, InstalledModMetadata> getInstalledModsMetadata(Server $server, ModrinthProjectType $modrinthProjectType)
* @method static bool saveModMetadata(Server $server, ModrinthProjectType $modrinthProjectType, string $projectId, string $projectSlug, string $projectTitle, string $versionId, string $versionNumber, string $filename, ?string $author = null)
* @method static bool removeModMetadata(Server $server, ModrinthProjectType $modrinthProjectType, string $projectId)
* @method static void downloadFile(Server $server, string $url, string $folder, string $filename)
* @method static void deleteFile(Server $server, string $folder, string $filename)
* @method static bool fileExists(Server $server, string $folder, string $filename)
* @method static array<string, mixed> listFolder(Server $server, string $folder)
* @method static InstalledModMetadata|null getInstalledMod(Server $server, ModrinthProjectType $modrinthProjectType, string $projectId)
* @method static bool isUpdateAvailable(array{version_id: string, version_number: string} $installedMod, array<int, array{id: string, version_number: string}> $availableVersions)
* @method static array<string> getInstalledMods(Server $server, ModrinthProjectType $modrinthProjectType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use App\Filament\Server\Resources\Files\Pages\ListFiles;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Traits\Filament\BlockAccessInConflict;
use Boy132\MinecraftModrinth\Enums\ModrinthProjectType;
use Boy132\MinecraftModrinth\Facades\MinecraftModrinth;
Expand All @@ -27,7 +26,6 @@
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\HtmlString;

Expand Down Expand Up @@ -132,6 +130,42 @@ protected function getCachedVersions(string $projectId): array
return $this->versionsCache[$projectId];
}

/**
* Fetch the versions of every project on the current page up front.
*
* Each installed row asks for its versions to decide between the "installed" and "update"
* action, so without this the installed tab does one Modrinth request per row, one after
* the other, and a full page can take longer than the request is allowed to run.
*
* @param array<int|string, string> $projectIds
*/
protected function primeVersionsCache(array $projectIds): void
{
$projectIds = array_values(array_diff(array_unique(array_filter($projectIds)), array_keys($this->versionsCache)));

if (count($projectIds) < 2) {
return;
}

/** @var Server $server */
$server = Filament::getTenant();

$this->versionsCache += MinecraftModrinth::getProjectVersionsBulk($projectIds, $server);
}

/**
* Drop everything read from the server so the next render reflects what is actually on disk.
*/
protected function forgetInstalledState(bool $refresh = true): void
{
$this->installedModsMetadata = null;
$this->versionsCache = [];

if ($refresh) {
$this->js('$wire.$refresh()');
}
}

/**
* @param array<int, array{primary: bool, filename: string, url: string}> $files
* @return array{primary: bool, filename: string, url: string}|null
Expand Down Expand Up @@ -174,17 +208,12 @@ private function performInstallOrUpdate(
array $primaryFile,
?array $installedMod = null
): void {
$fileRepository = app(DaemonFileRepository::class);

$safeNewFilename = $this->validateFilename($primaryFile['filename']);
$oldFilename = $installedMod ? $this->validateFilename($installedMod['filename']) : null;

$folder = static::$modrinthProjectType->getFolder();

$fileRepository
->setServer($server)
->pull($primaryFile['url'], $folder)
->throw();
MinecraftModrinth::downloadFile($server, $primaryFile['url'], $folder, $safeNewFilename);

$saved = MinecraftModrinth::saveModMetadata(
$server,
Expand All @@ -201,12 +230,7 @@ private function performInstallOrUpdate(
if (!$saved) {
if (!$oldFilename || $oldFilename !== $safeNewFilename) {
try {
Http::daemon($server->node)
->post("/api/servers/{$server->uuid}/files/delete", [
'root' => '/',
'files' => [$folder . '/' . $safeNewFilename],
])
->throw();
MinecraftModrinth::deleteFile($server, $folder, $safeNewFilename);
} catch (Exception $rollbackException) {
report($rollbackException);
}
Expand All @@ -217,20 +241,10 @@ private function performInstallOrUpdate(

if ($oldFilename && $oldFilename !== $safeNewFilename) {
try {
Http::daemon($server->node)
->post("/api/servers/{$server->uuid}/files/delete", [
'root' => '/',
'files' => [$folder . '/' . $oldFilename],
])
->throw();
MinecraftModrinth::deleteFile($server, $folder, $oldFilename);
} catch (Exception $deleteException) {
try {
Http::daemon($server->node)
->post("/api/servers/{$server->uuid}/files/delete", [
'root' => '/',
'files' => [$folder . '/' . $safeNewFilename],
])
->throw();
MinecraftModrinth::deleteFile($server, $folder, $safeNewFilename);
} catch (Exception $rollbackException) {
report($rollbackException);
}
Expand Down Expand Up @@ -275,12 +289,21 @@ public function table(Table $table): Table
}));
}

// Sort by title so a row keeps its place instead of moving around whenever its
// metadata entry is rewritten by an install, update or failed update.
usort($installedMods, fn (array $a, array $b) => strcasecmp($a['project_title'], $b['project_title']));

$projects = MinecraftModrinth::getInstalledModsFromModrinth($installedMods, $page);

$this->primeVersionsCache(array_column($projects, 'project_id'));

return new LengthAwarePaginator($projects, count($installedMods), 20, $page);
} else {
$response = MinecraftModrinth::getProjects($server, static::$modrinthProjectType, $page, $search);

$installedIds = array_column($this->getInstalledModsMetadata(), 'project_id');
$this->primeVersionsCache(array_intersect(array_column($response['hits'], 'project_id'), $installedIds));

return new LengthAwarePaginator($response['hits'], $response['total_hits'], 20, $page);
}
})
Expand Down Expand Up @@ -383,9 +406,7 @@ public function table(Table $table): Table

$this->performInstallOrUpdate($server, $record, $versionData, $primaryFile, $installedMod);

$this->installedModsMetadata = null;
$this->versionsCache = [];
$this->js('$wire.$refresh()');
$this->forgetInstalledState();

Notification::make()
->title(trans('minecraft-modrinth::strings.notifications.install_success'))
Expand All @@ -398,9 +419,7 @@ public function table(Table $table): Table
} catch (Exception $exception) {
report($exception);

$this->installedModsMetadata = null;
$this->versionsCache = [];
$this->js('$wire.$refresh()');
$this->forgetInstalledState();

Notification::make()
->title(trans('minecraft-modrinth::strings.notifications.install_failed'))
Expand Down Expand Up @@ -459,8 +478,7 @@ public function table(Table $table): Table

$this->performInstallOrUpdate($server, $record, $latestVersion, $primaryFile);

$this->installedModsMetadata = null;
$this->versionsCache = [];
$this->forgetInstalledState();

Notification::make()
->title(trans('minecraft-modrinth::strings.notifications.install_success'))
Expand All @@ -473,8 +491,7 @@ public function table(Table $table): Table
} catch (Exception $exception) {
report($exception);

$this->installedModsMetadata = null;
$this->versionsCache = [];
$this->forgetInstalledState();

Notification::make()
->title(trans('minecraft-modrinth::strings.notifications.install_failed'))
Expand Down Expand Up @@ -541,8 +558,7 @@ public function table(Table $table): Table

$this->performInstallOrUpdate($server, $record, $latestVersion, $primaryFile, $installedMod);

$this->installedModsMetadata = null;
$this->versionsCache = [];
$this->forgetInstalledState();

Notification::make()
->title(trans('minecraft-modrinth::strings.notifications.update_success'))
Expand All @@ -554,8 +570,7 @@ public function table(Table $table): Table
} catch (Exception $exception) {
report($exception);

$this->installedModsMetadata = null;
$this->versionsCache = [];
$this->forgetInstalledState();

Notification::make()
->title(trans('minecraft-modrinth::strings.notifications.update_failed'))
Expand Down Expand Up @@ -611,35 +626,29 @@ public function table(Table $table): Table

$folder = static::$modrinthProjectType->getFolder();

Http::daemon($server->node)
->post("/api/servers/{$server->uuid}/files/delete", [
'root' => '/',
'files' => [$folder . '/' . $safeFilename],
])
->throw();
MinecraftModrinth::deleteFile($server, $folder, $safeFilename);

$metadataRemoved = MinecraftModrinth::removeModMetadata($server, static::$modrinthProjectType, $record['project_id']);

$this->forgetInstalledState();

if (!$metadataRemoved) {
Log::warning('Failed to remove mod metadata after successful file deletion', [
'project_id' => $record['project_id'],
'server_id' => $server->id,
]);

if (is_array($this->installedModsMetadata)) {
$this->installedModsMetadata = array_values(
array_filter($this->installedModsMetadata, fn ($mod) => $mod['project_id'] !== $record['project_id'])
);
}

unset($this->versionsCache[$record['project_id']]);
} else {
$this->installedModsMetadata = null;
$this->versionsCache = [];
}

if ($this->activeTab === 'installed') {
$this->js('$wire.$refresh()');
// The jar is gone but the metadata file still lists it, so the row
// stays. Say so instead of reporting a clean uninstall.
Notification::make()
->title(trans('minecraft-modrinth::strings.notifications.uninstall_partial'))
->body(trans('minecraft-modrinth::strings.notifications.uninstall_partial_body', [
'name' => $record['title'],
]))
->warning()
->send();

return;
}

Notification::make()
Expand All @@ -652,12 +661,7 @@ public function table(Table $table): Table
} catch (Exception $exception) {
report($exception);

$this->installedModsMetadata = null;
$this->versionsCache = [];

if ($this->activeTab === 'installed') {
$this->js('$wire.$refresh()');
}
$this->forgetInstalledState();

Notification::make()
->title(trans('minecraft-modrinth::strings.notifications.uninstall_failed'))
Expand Down Expand Up @@ -703,20 +707,15 @@ public function content(Schema $schema): Schema
->badge(),
TextEntry::make('installed')
->label(fn () => trans('minecraft-modrinth::strings.page.installed', ['type' => static::$modrinthProjectType?->getLabel() ?? 'Modrinth']))
->state(function (DaemonFileRepository $fileRepository) use ($server) {
->state(function () use ($server) {
try {
$files = $fileRepository->setServer($server)->getDirectory(static::$modrinthProjectType->getFolder());

if (isset($files['error'])) {
throw new Exception($files['error']);
}

return collect($files)
->filter(fn ($file) => $file['mime'] === 'application/jar' || str($file['name'])->lower()->endsWith('.jar'))
return collect(MinecraftModrinth::listFolder($server, static::$modrinthProjectType->getFolder()))
->filter(fn ($file) => ($file['mime'] ?? null) === 'application/jar' || str($file['name'] ?? '')->lower()->endsWith('.jar'))
->count();
} catch (Exception $exception) {
report($exception);

// Don't pass off an unreachable daemon as an empty folder.
return trans('minecraft-modrinth::strings.page.unknown');
}
})
Expand Down
Loading