feat: add dependencies resolution for modrinth

This commit is contained in:
Kentai Radiquum 2025-05-14 21:47:39 +05:00
parent 50a1c8118e
commit c517795725
Signed by: Radiquum
GPG key ID: 858E8EE696525EED
6 changed files with 431 additions and 138 deletions

View file

@ -1,18 +1,15 @@
import { MOD_ENDPOINT } from "@/api/ENDPOINTS"; import { MOD_ENDPOINT } from "@/api/ENDPOINTS";
import { Mod } from "@/types/mod"; import { Mod } from "@/types/mod";
import { import { Button } from "flowbite-react";
Button,
Checkbox,
Table,
TableBody,
TableCell,
TableHead,
TableHeadCell,
TableRow,
} from "flowbite-react";
import { useState } from "react"; import { useState } from "react";
import { HiDownload, HiTrash } from "react-icons/hi"; import { HiDownload, HiTrash } from "react-icons/hi";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import {
Accordion,
AccordionContent,
AccordionPanel,
AccordionTitle,
} from "flowbite-react";
export const ModTable = (props: { export const ModTable = (props: {
mods: Mod[]; mods: Mod[];
@ -20,7 +17,13 @@ export const ModTable = (props: {
packID: string; packID: string;
downloadMods: (mods: string[]) => void; downloadMods: (mods: string[]) => void;
}) => { }) => {
const [selectedMods, setSelectedMods] = useState<string[]>([]); function bytesToSize(bytes) {
var sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes == 0) return "n/a";
var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
if (i == 0) return bytes + " " + sizes[i];
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + sizes[i];
}
async function deleteMod(slug: string, title: string) { async function deleteMod(slug: string, title: string) {
if (!window) return; if (!window) return;
@ -41,144 +44,151 @@ export const ModTable = (props: {
} }
} }
function selectAll() { if (!props.mods || props.mods.length == 0) {
const deselect = selectedMods.length == props.mods.length; return <></>;
console.log(selectedMods.length, props.mods.length, deselect);
if (deselect) {
setSelectedMods([]);
return;
}
props.mods.forEach((item) => {
if (!selectedMods.includes(item.slug)) {
setSelectedMods((state) => [item.slug, ...state]);
}
});
}
function handleCheckbox(slug: string) {
if (!selectedMods.includes(slug)) {
setSelectedMods((state) => [slug, ...state]);
} else {
const newArray = selectedMods.map((i) => i);
const idx = newArray.findIndex((item) => item == slug);
newArray.splice(idx, 1);
setSelectedMods(newArray);
}
}
async function deleteSelectedMods() {
await fetch(MOD_ENDPOINT("deleteModBulk", props.packID), {
method: "POST",
body: JSON.stringify(selectedMods),
headers: {
"content-type": "application/json",
accept: "application/json",
},
});
setSelectedMods([]);
props.updatePack();
} }
return ( return (
<div className="overflow-x-auto"> <Accordion>
<Table hoverable> {props.mods.map((mod) => {
<TableHead> return (
<TableRow> <AccordionPanel key={`mod-${mod.slug}`}>
<TableHeadCell className="p-4"> <AccordionTitle>
<Checkbox <div className="flex gap-2 items-center text-2xl">
checked={selectedMods.length == props.mods.length} <img alt="" src={mod.icon} className="w-8 h-8 rounded-lg" />
onChange={() => selectAll()} {mod.title} ({mod.slug})
/> </div>
</TableHeadCell> </AccordionTitle>
<TableHeadCell>Icon</TableHeadCell> <AccordionContent>
<TableHeadCell>Title</TableHeadCell> <div className="flex gap-8 flex-wrap">
<TableHeadCell>Version</TableHeadCell> <div className="flex gap-2 flex-col">
<TableHeadCell>Developer</TableHeadCell> <div>
<TableHeadCell>Source</TableHeadCell> <p className="font-semibold text-xl">Developers</p>
<TableHeadCell>Source URL</TableHeadCell> {mod.developers.join(", ")}
<TableHeadCell> </div>
<span className="sr-only">Actions</span> <div>
</TableHeadCell> <p className="font-semibold text-xl">Source</p>
</TableRow> <p>
</TableHead> <span className="font-semibold">title:</span> {mod.source}
<TableBody className="divide-y"> </p>
{props.mods && <p>
props.mods.length > 0 && <span className="font-semibold">id:</span>{" "}
props.mods.map((mod) => { {mod.project_id}
return ( </p>
<TableRow <p>
key={`mod-${mod.slug}`} <span className="font-semibold">link:</span> {mod.url}
className="bg-white dark:border-gray-700 dark:bg-gray-800" </p>
> </div>
<TableCell className="p-4"> </div>
<Checkbox <div>
checked={selectedMods.includes(mod.slug)} <p className="font-semibold text-xl">Version info</p>
onChange={() => { <p>
handleCheckbox(mod.slug); <span className="font-semibold">filename:</span>{" "}
}} {mod.file.filename}
/> </p>
</TableCell> <p>
<TableCell> <span className="font-semibold">version:</span>{" "}
{/* eslint-disable-next-line @next/next/no-img-element */} {mod.file.version}
<img alt="" src={mod.icon} className="w-8 h-8 rounded-lg" /> </p>
</TableCell> <p>
<TableCell className="whitespace-nowrap font-medium text-gray-900 dark:text-white"> <span className="font-semibold">file size:</span>{" "}
{mod.title} {bytesToSize(mod.file.size)}
</TableCell> </p>
<TableCell>{mod.file.version}</TableCell> </div>
<TableCell>{mod.developers.join(", ")}</TableCell> <div>
<TableCell>{mod.source}</TableCell> <p className="font-semibold text-xl">Environment</p>
<TableCell>{mod.url}</TableCell> {mod.environment.client && <p>client</p>}
<TableCell> {mod.environment.server && <p>server</p>}
<div className="flex gap-2"> {mod.environment.client && mod.environment.server && (
<Button <p>client & server</p>
size="sm" )}
onClick={() => props.downloadMods([mod.slug])} {/* <p>
> <span className="font-semibold">filename:</span>{" "}
Download <HiDownload className="ml-2 h-4 w-4" /> {mod.file.filename}
</Button> </p>
<Button <p>
color={"red"} <span className="font-semibold">version:</span>{" "}
size="sm" {mod.file.version}
onClick={() => deleteMod(mod.slug, mod.title)} </p>
> <p>
Delete <HiTrash className="ml-2 h-4 w-4" /> <span className="font-semibold">file size:</span>{" "}
</Button> {bytesToSize(mod.file.size)}
</div> </p> */}
</TableCell> </div>
</TableRow> </div>
); <div className="mt-2">
})} <p className="font-semibold text-xl">Hashes</p>
<TableRow className="bg-white dark:bg-[#374151] hover:bg-white! hover:dark:bg-[#374151]! dark:border-gray-700"> {Object.entries(mod.file.hashes).map((hash) => {
<TableCell className="p-4"></TableCell> return (
<TableCell></TableCell> <p
<TableCell></TableCell> key={`mod-${mod.slug}-hash-${hash[0]}`}
<TableCell></TableCell> className="wrap-break-word"
<TableCell></TableCell> >
<TableCell></TableCell> <span className="font-semibold">{hash[0]}:</span>{" "}
<TableCell></TableCell> {hash[1]}
<TableCell> </p>
<div className="flex gap-2"> );
})}
</div>
{mod.dependencies.length > 0 ? (
<div className="mt-2">
<p className="font-semibold text-xl mb-1">Dependencies</p>
<div className="flex gap-2 overflow-x-auto overflow-y-hidden">
{mod.dependencies.map((dep) => {
return (
<div
key={`mod-${mod.slug}-dep-${dep.slug}`}
className="bg-[#f3f4f6] dark:bg-[#1f2937] p-4 rounded-lg"
>
<div className="flex gap-2 items-center text-xl">
<img
alt=""
src={dep.icon}
className="w-6 h-6 rounded-lg"
/>
{dep.title} ({dep.slug})
</div>
<div className="mt-1">
<p>
<span className="font-semibold">filename:</span>{" "}
{dep.file.filename}
</p>
<p>
<span className="font-semibold">version:</span>{" "}
{dep.file.version}
</p>
<p>
<span className="font-semibold">file size:</span>{" "}
{bytesToSize(dep.file.size)}
</p>
</div>
</div>
);
})}
</div>
</div>
) : (
""
)}
<div className="flex justify-end w-full gap-2 mt-4">
<Button <Button
size="sm" size="sm"
disabled={selectedMods.length == 0} onClick={() => props.downloadMods([mod.slug])}
onClick={() => props.downloadMods(selectedMods)}
> >
Download Selected <HiDownload className="ml-2 h-4 w-4" /> Download <HiDownload className="ml-2 h-4 w-4" />
</Button> </Button>
<Button <Button
color={"red"} color={"red"}
size="sm" size="sm"
disabled={selectedMods.length == 0} onClick={() => deleteMod(mod.slug, mod.title)}
onClick={() => deleteSelectedMods()}
> >
Delete Selected <HiTrash className="ml-2 h-4 w-4" /> Delete <HiTrash className="ml-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</TableCell> </AccordionContent>
</TableRow> </AccordionPanel>
</TableBody> );
</Table> })}
</div> </Accordion>
); );
}; };

View file

@ -0,0 +1,234 @@
import { MOD_ENDPOINT } from "@/api/ENDPOINTS";
import { Mod } from "@/types/mod";
import {
Button,
Checkbox,
Table,
TableBody,
TableCell,
TableHead,
TableHeadCell,
TableRow,
} from "flowbite-react";
import { useState } from "react";
import { HiDownload, HiTrash } from "react-icons/hi";
import { toast } from "react-toastify";
export const ModTable = (props: {
mods: Mod[];
updatePack: () => void;
packID: string;
downloadMods: (mods: string[]) => void;
}) => {
const [selectedMods, setSelectedMods] = useState<string[]>([]);
async function deleteMod(slug: string, title: string) {
if (!window) return;
if (window.confirm(`Delete mod ${title}?`)) {
const res = await fetch(MOD_ENDPOINT("deleteMod", props.packID, slug));
const data = await res.json();
if (data.status != "ok") {
toast.error(data.message, {
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
return;
}
props.updatePack();
}
}
function selectAll() {
const deselect = selectedMods.length == props.mods.length;
console.log(selectedMods.length, props.mods.length, deselect);
if (deselect) {
setSelectedMods([]);
return;
}
props.mods.forEach((item) => {
if (!selectedMods.includes(item.slug)) {
setSelectedMods((state) => [item.slug, ...state]);
}
});
}
function handleCheckbox(slug: string) {
if (!selectedMods.includes(slug)) {
setSelectedMods((state) => [slug, ...state]);
} else {
const newArray = selectedMods.map((i) => i);
const idx = newArray.findIndex((item) => item == slug);
newArray.splice(idx, 1);
setSelectedMods(newArray);
}
}
async function deleteSelectedMods() {
await fetch(MOD_ENDPOINT("deleteModBulk", props.packID), {
method: "POST",
body: JSON.stringify(selectedMods),
headers: {
"content-type": "application/json",
accept: "application/json",
},
});
setSelectedMods([]);
props.updatePack();
}
return (
<div className="overflow-x-auto">
<Table hoverable>
<TableHead>
<TableRow>
<TableHeadCell className="p-4">
<Checkbox
checked={selectedMods.length == props.mods.length}
onChange={() => selectAll()}
/>
</TableHeadCell>
<TableHeadCell>Icon</TableHeadCell>
<TableHeadCell>Title</TableHeadCell>
<TableHeadCell>Version</TableHeadCell>
<TableHeadCell>Developer</TableHeadCell>
<TableHeadCell>Source</TableHeadCell>
<TableHeadCell>Source URL</TableHeadCell>
<TableHeadCell>
<span className="sr-only">Actions</span>
</TableHeadCell>
</TableRow>
</TableHead>
<TableBody className="divide-y">
{props.mods &&
props.mods.length > 0 &&
props.mods.map((mod) => {
return (
<>
<TableRow
key={`mod-${mod.slug}`}
className="bg-white dark:border-gray-700 dark:bg-gray-800"
>
<TableCell className="p-4">
<Checkbox
checked={selectedMods.includes(mod.slug)}
onChange={() => {
handleCheckbox(mod.slug);
}}
/>
</TableCell>
<TableCell>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
src={mod.icon}
className="w-8 h-8 rounded-lg"
/>
</TableCell>
<TableCell className="whitespace-nowrap font-medium text-gray-900 dark:text-white">
{mod.title}
</TableCell>
<TableCell>{mod.file.version}</TableCell>
<TableCell>{mod.developers.join(", ")}</TableCell>
<TableCell>{mod.source}</TableCell>
<TableCell>{mod.url}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => props.downloadMods([mod.slug])}
>
Download <HiDownload className="ml-2 h-4 w-4" />
</Button>
<Button
color={"red"}
size="sm"
onClick={() => deleteMod(mod.slug, mod.title)}
>
Delete <HiTrash className="ml-2 h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
{mod.dependencies &&
mod.dependencies.length > 0 &&
mod.dependencies.map((dep) => {
return (
<TableRow
key={`mod-${mod.slug}-dep-${dep.slug}`}
className="bg-white dark:border-gray-700 dark:bg-gray-800"
>
<TableCell className="p-4"></TableCell>
<TableCell>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
src={dep.icon}
className="w-8 h-8 rounded-lg"
/>
</TableCell>
<TableCell className="whitespace-nowrap font-medium text-gray-900 dark:text-white">
{dep.title}
</TableCell>
<TableCell>{dep.file.version}</TableCell>
<TableCell>{dep.developers.join(", ")}</TableCell>
<TableCell>{dep.source}</TableCell>
<TableCell>{dep.url}</TableCell>
<TableCell>
{/* <div className="flex gap-2">
<Button
size="sm"
onClick={() => props.downloadMods([mod.slug])}
>
Download <HiDownload className="ml-2 h-4 w-4" />
</Button>
<Button
color={"red"}
size="sm"
onClick={() => deleteMod(mod.slug, mod.title)}
>
Delete <HiTrash className="ml-2 h-4 w-4" />
</Button>
</div> */}
</TableCell>
</TableRow>
);
})}
</>
);
})}
<TableRow className="bg-white dark:bg-[#374151] hover:bg-white! hover:dark:bg-[#374151]! dark:border-gray-700">
<TableCell className="p-4"></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
disabled={selectedMods.length == 0}
onClick={() => props.downloadMods(selectedMods)}
>
Download Selected <HiDownload className="ml-2 h-4 w-4" />
</Button>
<Button
color={"red"}
size="sm"
disabled={selectedMods.length == 0}
onClick={() => deleteSelectedMods()}
>
Delete Selected <HiTrash className="ml-2 h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
};

View file

@ -1,6 +1,6 @@
export type ModFile = { export type ModFile = {
"version": string, "version": string,
"hashes": unknown, "hashes": Record<string, string>,
"url": string, "url": string,
"filename": string, "filename": string,
"size": number, "size": number,

View file

@ -2,6 +2,7 @@ import { ModFile } from "./file";
export type Mod = { export type Mod = {
"slug": string, "slug": string,
"project_id": string;
"icon": string, "icon": string,
"title":string, "title":string,
"developers": string[], "developers": string[],
@ -11,5 +12,6 @@ export type Mod = {
"client": boolean, "client": boolean,
"server": boolean, "server": boolean,
}, },
"dependencies": Mod[],
"file": ModFile, "file": ModFile,
} }

View file

@ -16,6 +16,10 @@ def getCurseForgeMod(slug, version, mod_loader, game_version):
return { return {
"status": "error", "status": "error",
"message": f"failed to fetch curseforge mod: {metaR.status_code}", "message": f"failed to fetch curseforge mod: {metaR.status_code}",
"slug": slug,
"version": version,
"mod_loader": mod_loader,
"game_version": game_version
} }
meta: dict = metaR.json() meta: dict = metaR.json()
@ -42,6 +46,10 @@ def getCurseForgeMod(slug, version, mod_loader, game_version):
return { return {
"status": "error", "status": "error",
"message": f"failed to fetch curseforge mod versions: {versR.status_code}", "message": f"failed to fetch curseforge mod versions: {versR.status_code}",
"slug": slug,
"version": version,
"mod_loader": mod_loader,
"game_version": game_version
} }
vers: dict = versR.json() vers: dict = versR.json()
@ -49,6 +57,10 @@ def getCurseForgeMod(slug, version, mod_loader, game_version):
return { return {
"status": "error", "status": "error",
"message": f"mod is not compatible with this game version or mod loader", "message": f"mod is not compatible with this game version or mod loader",
"slug": slug,
"version": version,
"mod_loader": mod_loader,
"game_version": game_version
} }
if version: if version:
@ -64,10 +76,13 @@ def getCurseForgeMod(slug, version, mod_loader, game_version):
for hash in selected_version.get("hashes"): for hash in selected_version.get("hashes"):
hashes[HASHALGO_ENUM[hash.get("algo")]] = hash.get("value") hashes[HASHALGO_ENUM[hash.get("algo")]] = hash.get("value")
dependencies = []
return { return {
"status": "ok", "status": "ok",
"mod": { "mod": {
"slug": slug, "slug": slug,
"project_id": meta.get("id"),
"icon": meta.get("logo").get("url"), "icon": meta.get("logo").get("url"),
"title": meta.get("name"), "title": meta.get("name"),
"developers": developers, "developers": developers,
@ -77,6 +92,7 @@ def getCurseForgeMod(slug, version, mod_loader, game_version):
"client": True, "client": True,
"server": True, "server": True,
}, },
"dependencies": dependencies,
"file": { "file": {
"version": selected_version.get("id"), "version": selected_version.get("id"),
"hashes": hashes, "hashes": hashes,

View file

@ -10,6 +10,10 @@ def getModrinthMod(slug, version, mod_loader, game_version):
return { return {
"status": "error", "status": "error",
"message": f"failed to fetch modrinth description: {descR.status_code}", "message": f"failed to fetch modrinth description: {descR.status_code}",
"slug": slug,
"version": version,
"mod_loader": mod_loader,
"game_version": game_version
} }
versR = requests.get( versR = requests.get(
@ -21,6 +25,10 @@ def getModrinthMod(slug, version, mod_loader, game_version):
return { return {
"status": "error", "status": "error",
"message": f"failed to fetch modrinth mod versions: {versR.status_code}", "message": f"failed to fetch modrinth mod versions: {versR.status_code}",
"slug": slug,
"version": version,
"mod_loader": mod_loader,
"game_version": game_version
} }
devsR = requests.get( devsR = requests.get(
@ -32,6 +40,10 @@ def getModrinthMod(slug, version, mod_loader, game_version):
return { return {
"status": "error", "status": "error",
"message": f"failed to fetch modrinth mod developers: {devsR.status_code}", "message": f"failed to fetch modrinth mod developers: {devsR.status_code}",
"slug": slug,
"version": version,
"mod_loader": mod_loader,
"game_version": game_version
} }
desc: dict = descR.json() desc: dict = descR.json()
@ -42,6 +54,10 @@ def getModrinthMod(slug, version, mod_loader, game_version):
return { return {
"status": "error", "status": "error",
"message": f"mod is not compatible with this game version or mod loader", "message": f"mod is not compatible with this game version or mod loader",
"slug": slug,
"version": version,
"mod_loader": mod_loader,
"game_version": game_version
} }
selected_version = vers[0] selected_version = vers[0]
@ -61,6 +77,10 @@ def getModrinthMod(slug, version, mod_loader, game_version):
return { return {
"status": "error", "status": "error",
"message": f"failed to get primary mod file", "message": f"failed to get primary mod file",
"slug": slug,
"version": version,
"mod_loader": mod_loader,
"game_version": game_version
} }
developers = [] developers = []
@ -74,10 +94,20 @@ def getModrinthMod(slug, version, mod_loader, game_version):
if desc.get("server_side") in ["optional", "required"]: if desc.get("server_side") in ["optional", "required"]:
isServer = True isServer = True
dependencies = []
for dep in selected_version.get("dependencies"):
depDescR = requests.get(f"https://api.modrinth.com/v2/project/{dep.get('project_id')}", headers=headers)
if depDescR.status_code != 200:
continue
depDesc: dict = depDescR.json()
depMod = getModrinthMod(depDesc.get("slug"), None, mod_loader, game_version)
dependencies.append(depMod.get("mod"))
return { return {
"status": "ok", "status": "ok",
"mod": { "mod": {
"slug": slug, "slug": slug,
"project_id": desc.get("id"),
"icon": desc.get("icon_url"), "icon": desc.get("icon_url"),
"title": desc.get("title"), "title": desc.get("title"),
"developers": developers, "developers": developers,
@ -87,6 +117,7 @@ def getModrinthMod(slug, version, mod_loader, game_version):
"client": isClient, "client": isClient,
"server": isServer, "server": isServer,
}, },
"dependencies": dependencies,
"file": { "file": {
"version": selected_version.get("version_number"), "version": selected_version.get("version_number"),
"hashes": primary_file.get("hashes"), "hashes": primary_file.get("hashes"),