feat: Finished Curseforge implementation?

This commit is contained in:
LynithDev 2024-10-08 18:06:43 +02:00
parent 518c7ec8b9
commit a1f8df7f49
8 changed files with 298 additions and 170 deletions

View file

@ -149,6 +149,7 @@ pub async fn download_provider_package(
package_version, package_version,
) )
.await?; .await?;
package::add_package( package::add_package(
&cluster.cluster_path(), &cluster.cluster_path(),
pkg_path, pkg_path,

View file

@ -1,15 +1,12 @@
import type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, SearchResult } from '@onelauncher/client/bindings'; import type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, SearchResult } from '@onelauncher/client/bindings';
import type { ModalProps } from '~ui/components/overlay/Modal';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { bridge } from '~imports'; import { bridge } from '~imports';
import Button from '~ui/components/base/Button'; import { createModal } from '~ui/components/overlay/Modal';
import Dropdown from '~ui/components/base/Dropdown';
import Modal, { createModal } from '~ui/components/overlay/Modal';
import BrowserPackage from '~ui/pages/browser/BrowserPackage'; import BrowserPackage from '~ui/pages/browser/BrowserPackage';
import { PROVIDERS } from '~utils'; import { PROVIDERS } from '~utils';
import { type Accessor, type Context, createContext, createEffect, createSignal, For, on, onMount, type ParentProps, type Setter, Show, untrack, useContext } from 'solid-js'; import { type Accessor, type Context, createContext, createEffect, createSignal, on, onMount, type ParentProps, type Setter, useContext } from 'solid-js';
import { useRecentCluster } from './useCluster'; import { ChooseClusterModal, useRecentCluster } from './useCluster';
import useCommand, { tryResult } from './useCommand'; import { tryResult } from './useCommand';
export type ProviderSearchOptions = ProviderSearchQuery & { provider: Providers }; export type ProviderSearchOptions = ProviderSearchQuery & { provider: Providers };
export type PopularPackages = Record<Providers, SearchResult[]>; export type PopularPackages = Record<Providers, SearchResult[]>;
@ -60,8 +57,30 @@ export function BrowserProvider(props: ParentProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const recentCluster = useRecentCluster(); const recentCluster = useRecentCluster();
const currentlySelectedCluster = (list: Cluster[]) => {
const cluster = controller.cluster();
if (cluster === undefined)
return 0;
if (list === undefined)
return 0;
const index = list.findIndex(c => c.uuid === cluster.uuid) || 0;
if (index === -1)
return 0;
return index;
};
const chooseCluster = (cluster: Cluster) => {
controller.setCluster(cluster);
};
const modal = createModal(props => ( const modal = createModal(props => (
<ChooseClusterModal <ChooseClusterModal
onSelected={chooseCluster}
selected={list => currentlySelectedCluster(list) || 0}
{...props} {...props}
/> />
)); ));
@ -163,73 +182,3 @@ export default function useBrowser() {
return controller; return controller;
} }
function ChooseClusterModal(props: ModalProps) {
const [selected, setSelected] = createSignal<number>(0);
const [clusters] = useCommand(() => bridge.commands.getClusters());
const controller = useBrowser();
const currentlySelectedCluster = (list: Cluster[]) => {
const cluster = controller.cluster();
if (cluster === undefined)
return 0;
if (list === undefined)
return 0;
const index = list.findIndex(c => c.uuid === cluster.uuid) || 0;
if (index === -1)
return 0;
return index;
};
createEffect(() => {
setSelected(currentlySelectedCluster(clusters() || []));
});
function chooseCluster() {
const index = untrack(selected);
const clusterz = untrack(clusters);
if (clusterz !== undefined && index !== undefined)
controller.setCluster(clusterz[index]);
props.hide();
}
return (
<Modal.Simple
{...props}
buttons={[
<Button
buttonStyle="secondary"
children="Close"
onClick={props.hide}
/>,
<Button
children="Save"
onClick={chooseCluster}
/>,
]}
children={(
<Show
fallback={<div>Loading...</div>}
when={clusters !== undefined}
>
<Dropdown
onChange={setSelected}
selected={selected}
>
<For each={clusters()!}>
{cluster => (
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
)}
</For>
</Dropdown>
</Show>
)}
title="Choose a cluster"
/>
);
}

View file

@ -1,9 +1,11 @@
import type { Cluster } from '@onelauncher/client/bindings'; import type { Cluster } from '@onelauncher/client/bindings';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { bridge } from '~imports'; import { bridge } from '~imports';
import Modal, { createModal } from '~ui/components/overlay/Modal'; import Button from '~ui/components/base/Button';
import Dropdown from '~ui/components/base/Dropdown';
import Modal, { createModal, type ModalProps } from '~ui/components/overlay/Modal';
import ClusterGame from '~ui/pages/cluster/ClusterGame'; import ClusterGame from '~ui/pages/cluster/ClusterGame';
import { type Accessor, type Context, createContext, createEffect, createSignal, type ParentProps, type ResourceReturn, Show, useContext } from 'solid-js'; import { type Accessor, type Context, createContext, createEffect, createResource, createSignal, For, Match, onMount, type ParentProps, type ResourceReturn, Show, Switch, untrack, useContext } from 'solid-js';
import useCommand, { tryResult } from './useCommand'; import useCommand, { tryResult } from './useCommand';
const ClusterContext = createContext() as Context<ResourceReturn<Cluster>>; const ClusterContext = createContext() as Context<ResourceReturn<Cluster>>;
@ -104,3 +106,82 @@ export function useRecentCluster() {
} }
export default useClusterContext; export default useClusterContext;
type ChooseClusterModalProps = ModalProps & {
clusters?: () => Cluster[];
selected?: (clusters: Cluster[]) => number;
onSelected?: (cluster: Cluster) => void;
};
export function ChooseClusterModal(props: ChooseClusterModalProps) {
const [clusters] = createResource(async () => {
if (props.clusters !== undefined)
return await props.clusters();
return await tryResult(bridge.commands.getClusters);
});
const [selected, setSelected] = createSignal<number>(0);
onMount(() => {
const newSelected = props.selected?.(clusters() || []);
if (newSelected !== undefined)
setSelected(newSelected);
});
function chooseCluster() {
const index = untrack(selected);
const clusterz = untrack(clusters);
if (clusterz !== undefined && index !== undefined) {
const cluster = clusterz[index];
if (cluster !== undefined)
props.onSelected?.(cluster);
}
props.hide();
}
return (
<Modal.Simple
{...props}
buttons={[
<Button
buttonStyle="secondary"
children="Close"
onClick={props.hide}
/>,
<Button
children="Save"
onClick={chooseCluster}
/>,
]}
children={(
<Switch>
<Match when={clusters() !== undefined}>
<Dropdown
disabled={clusters()?.length === 0}
onChange={setSelected}
selected={selected}
>
<Switch>
<Match when={(clusters()?.length || 0) > 0}>
<For each={clusters()!}>
{cluster => (
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
)}
</For>
</Match>
<Match when>
<Dropdown.Row>No clusters found</Dropdown.Row>
</Match>
</Switch>
</Dropdown>
</Match>
<Match when>
<div>Loading...</div>
</Match>
</Switch>
)}
title="Choose a cluster"
/>
);
}

View file

@ -9,13 +9,15 @@ import Dropdown from '~ui/components/base/Dropdown';
import Link from '~ui/components/base/Link'; import Link from '~ui/components/base/Link';
import Tooltip from '~ui/components/base/Tooltip'; import Tooltip from '~ui/components/base/Tooltip';
import Markdown from '~ui/components/content/Markdown'; import Markdown from '~ui/components/content/Markdown';
import { createModal } from '~ui/components/overlay/Modal';
import Spinner from '~ui/components/Spinner'; import Spinner from '~ui/components/Spinner';
import useBrowser from '~ui/hooks/useBrowser'; import useBrowser from '~ui/hooks/useBrowser';
import { ChooseClusterModal } from '~ui/hooks/useCluster';
import useCommand, { tryResult } from '~ui/hooks/useCommand'; import useCommand, { tryResult } from '~ui/hooks/useCommand';
import usePagination from '~ui/hooks/usePagination'; import usePagination from '~ui/hooks/usePagination';
import usePromptOpener from '~ui/hooks/usePromptOpener'; import usePromptOpener from '~ui/hooks/usePromptOpener';
import { abbreviateNumber, formatAsRelative } from '~utils'; import { abbreviateNumber, formatAsRelative } from '~utils';
import { type Accessor, createContext, createEffect, createResource, createSignal, For, Match, on, type ParentProps, type Resource, Show, Switch, useContext } from 'solid-js'; import { type Accessor, createContext, createEffect, createMemo, createResource, createSignal, For, Match, on, type ParentProps, type Resource, Show, Switch, useContext } from 'solid-js';
import { getLicenseUrl, getPackageUrl, upperFirst } from '../../../utils'; import { getLicenseUrl, getPackageUrl, upperFirst } from '../../../utils';
interface BrowserModParams extends Params { interface BrowserModParams extends Params {
@ -270,7 +272,7 @@ function InstallButton(props: ManagedPackage) {
return game_version && loader; return game_version && loader;
}; };
const filtered = () => clusters()?.filter(meetsRequirements); const filtered = createMemo(() => clusters()?.filter(meetsRequirements));
const getSelectedCluster = () => filtered()?.[selected()]; const getSelectedCluster = () => filtered()?.[selected()];
@ -312,44 +314,44 @@ function InstallButton(props: ManagedPackage) {
}); });
return ( return (
<div class="h-12 flex flex-row"> <Show when={(filtered()?.length || 0) > 0}>
<div class="h-12 flex flex-row">
<Button <Button
buttonStyle="primary" buttonStyle="primary"
children={( children={(
<div class="flex flex-1 flex-col items-center justify-center"> <div class="flex flex-1 flex-col items-center justify-center">
<p class="text-xs">Download latest to</p> <p class="text-xs">Download latest to</p>
<span class="mt-0.5 h-3.5 max-w-38 overflow-x-hidden text-sm font-bold">{filtered?.()?.[selected?.()]?.meta.name || 'Unknown'}</span> <span class="mt-0.5 h-3.5 max-w-38 overflow-x-hidden text-sm font-bold">{filtered?.()?.[selected?.()]?.meta.name || 'Unknown'}</span>
</div> </div>
)}
class="max-w-full flex-1 rounded-r-none!"
disabled={filtered()?.length === 0}
iconLeft={<Download01Icon />}
onClick={download}
/>
<Dropdown
class="w-8"
component={props => (
<Button
buttonStyle="primary"
class="h-full w-full border-l border-white/5 rounded-l-none! px-0!"
iconLeft={<ChevronDownIcon />}
onClick={() => props.setVisible(true)}
/>
)}
disabled={filtered()?.length === 0}
dropdownClass="w-58! right-0"
onChange={setSelected}
selected={selected}
>
<For each={filtered()}>
{cluster => (
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
)} )}
</For> class="max-w-full flex-1 rounded-r-none!"
</Dropdown> iconLeft={<Download01Icon />}
</div> onClick={download}
/>
<Dropdown
class="w-8"
component={props => (
<Button
buttonStyle="primary"
class="h-full w-full border-l border-white/5 rounded-l-none! px-0!"
iconLeft={<ChevronDownIcon />}
onClick={() => props.setVisible(true)}
/>
)}
dropdownClass="w-58! right-0"
onChange={setSelected}
selected={selected}
>
<For each={filtered()}>
{cluster => (
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
)}
</For>
</Dropdown>
</div>
</Show>
); );
} }
@ -417,7 +419,7 @@ function BrowserPackageVersions() {
const list = getVersionsForPage(page()); const list = getVersionsForPage(page());
return await tryResult(() => bridge.commands.getProviderPackageVersions(context.pkg().provider, list)); return (await tryResult(() => bridge.commands.getProviderPackageVersions(context.pkg().provider, list))).toReversed();
}); });
let container!: HTMLDivElement; let container!: HTMLDivElement;
@ -455,7 +457,7 @@ function BrowserPackageVersions() {
</thead> </thead>
<tbody> <tbody>
<For each={versions()?.reverse()}> <For each={versions() || []}>
{version => <VersionRow {...version} />} {version => <VersionRow {...version} />}
</For> </For>
</tbody> </tbody>
@ -469,7 +471,7 @@ function BrowserPackageVersions() {
} }
function colorForType(type: string) { function colorForType(type: string) {
switch (type) { switch (type.toLowerCase()) {
case 'release': case 'release':
return 'bg-code-trace'; return 'bg-code-trace';
case 'snapshot': case 'snapshot':
@ -484,6 +486,59 @@ function colorForType(type: string) {
} }
function VersionRow(props: ManagedVersion) { function VersionRow(props: ManagedVersion) {
const [clusters, setClusters] = createSignal<Cluster[]>([]);
const context = useContext(BrowserPackageContext);
async function getClusters() {
const clusters = await tryResult(bridge.commands.getClusters);
return clusters.filter((cluster) => {
const game_version = props.game_versions.includes(cluster.meta.mc_version);
const loader = props.loaders.includes(cluster.meta.loader || 'vanilla');
return game_version && loader;
});
}
const modal = createModal((props) => {
return (
<ChooseClusterModal
clusters={clusters}
onSelected={downloadPackage}
{...props}
/>
);
});
async function downloadPrompted() {
const clusterz = await getClusters();
setClusters(clusterz);
modal.show();
}
async function downloadPackage(cluster: Cluster) {
if (cluster === undefined)
return;
try {
// eslint-disable-next-line solid/reactivity -- lint issue
await tryResult(() => bridge.commands.downloadProviderPackage(
context!.pkg()!.provider,
context!.pkg()!.id,
cluster.uuid,
cluster.meta.mc_version,
cluster.meta.loader || null,
props.id,
));
await bridge.commands.syncClusterPackages(cluster.path || '');
}
catch (err) {
console.error(err);
}
}
return ( return (
<tr class="my-2 bg-page-elevated px-4 [&>td]:py-4"> <tr class="my-2 bg-page-elevated px-4 [&>td]:py-4">
<td class="rounded-l-lg px-4"> <td class="rounded-l-lg px-4">
@ -533,6 +588,7 @@ function VersionRow(props: ManagedVersion) {
<Button <Button
buttonStyle="iconSecondary" buttonStyle="iconSecondary"
children={<Download01Icon />} children={<Download01Icon />}
onClick={downloadPrompted}
/> />
</td> </td>

View file

@ -6,7 +6,6 @@ import Button from '~ui/components/base/Button';
import TextField from '~ui/components/base/TextField'; import TextField from '~ui/components/base/TextField';
import ScrollableContainer from '~ui/components/ScrollableContainer'; import ScrollableContainer from '~ui/components/ScrollableContainer';
import Sidebar from '~ui/components/Sidebar'; import Sidebar from '~ui/components/Sidebar';
import Spinner from '~ui/components/Spinner';
import useBrowser from '~ui/hooks/useBrowser'; import useBrowser from '~ui/hooks/useBrowser';
import useClusterContext from '~ui/hooks/useCluster'; import useClusterContext from '~ui/hooks/useCluster';
import useCommand, { tryResult } from '~ui/hooks/useCommand'; import useCommand, { tryResult } from '~ui/hooks/useCommand';
@ -39,19 +38,10 @@ function ClusterMods() {
} }
return formatted; return formatted;
}, {
initialValue: new Map<Providers, ManagedPackage[]>(),
}); });
function getManagedData(mod: Package): ManagedPackage | undefined {
const meta = mod.meta;
if (meta.type === 'managed') {
const provider = managedMods()?.get(meta.provider);
if (provider && provider.length > 0)
return provider.find(pkg => pkg.id === meta.package_id);
}
return undefined;
}
// Mods to display, should be a clone of the mods array but sorted // Mods to display, should be a clone of the mods array but sorted
const [displayedMods, setDisplayedMods] = createSignal<Package[]>([]); const [displayedMods, setDisplayedMods] = createSignal<Package[]>([]);
@ -125,24 +115,19 @@ function ClusterMods() {
</div> </div>
<ScrollableContainer> <ScrollableContainer>
<Spinner.Suspense> <Switch>
<Show when={managedMods.loading === false}> <Match when={displayedMods().length > 0}>
<Switch> <For each={displayedMods()}>
<Match when={displayedMods().length > 0}> {mod => (
<For each={displayedMods()}> <ModEntry managedMods={managedMods()} pkg={mod} refetch={refetch} />
{(mod) => { )}
const managed = getManagedData(mod); </For>
return <ModEntry managed={managed} pkg={mod} refetch={refetch} />; </Match>
}}
</For>
</Match>
<Match when={displayedMods().length === 0}> <Match when={displayedMods().length === 0}>
<p class="my-4 text-center text-2lg">No mods were found</p> <p class="my-4 text-center text-2lg">No mods were found</p>
</Match> </Match>
</Switch> </Switch>
</Show>
</Spinner.Suspense>
</ScrollableContainer> </ScrollableContainer>
</Sidebar.Page> </Sidebar.Page>
); );
@ -153,13 +138,23 @@ export default ClusterMods;
interface ModEntryProps { interface ModEntryProps {
pkg: Package; pkg: Package;
refetch: () => void; refetch: () => void;
managed?: ManagedPackage | undefined; managedMods?: Map<Providers, ManagedPackage[]>;
}; };
function ModEntry(props: ModEntryProps) { function ModEntry(props: ModEntryProps) {
const [cluster] = useClusterContext(); const [cluster] = useClusterContext();
const browser = useBrowser(); const browser = useBrowser();
const managed = () => {
const pkg = props.pkg;
if (props.managedMods && pkg.meta.type === 'managed') {
const package_id = pkg.meta.package_id;
return props.managedMods.get(pkg.meta.provider)?.find(p => p.id === package_id);
}
return undefined;
};
const name = () => { const name = () => {
if (props.pkg.meta.type === 'unknown') if (props.pkg.meta.type === 'unknown')
return props.pkg.file_name; return props.pkg.file_name;
@ -191,17 +186,19 @@ function ModEntry(props: ModEntryProps) {
const icon = () => { const icon = () => {
if (props.pkg.meta.type === 'managed') if (props.pkg.meta.type === 'managed')
return props.managed?.icon_url; return managed()?.icon_url;
return undefined; return undefined;
}; };
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {
if (props.managed) { const mngd = managed();
if (mngd) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
browser.displayPackage(props.managed.id, props.managed.provider); browser.displayPackage(mngd.id, mngd.provider);
} }
} }

View file

@ -36,6 +36,10 @@ pub async fn find_managed_version(
Ok(versions.0 Ok(versions.0
.iter() .iter()
.find(|v| { .find(|v| {
if package_version.is_some() {
return v.id == *package_version.as_ref().unwrap();
}
let check_game_version = game_version let check_game_version = game_version
.as_ref() .as_ref()
.is_some_and(|gv| v.game_versions.iter().any(|gv2| *gv2 == *gv)); .is_some_and(|gv| v.game_versions.iter().any(|gv2| *gv2 == *gv));
@ -44,11 +48,7 @@ pub async fn find_managed_version(
.as_ref() .as_ref()
.is_some_and(|loader| v.loaders.iter().any(|l| *l == *loader)); .is_some_and(|loader| v.loaders.iter().any(|l| *l == *loader));
let check_package_version = package_version check_game_version && check_loader
.as_ref()
.map_or(true, |pv| pv == &v.version_display);
check_game_version && check_loader && check_package_version
}) })
.ok_or_else(|| anyhow::anyhow!("no matching version found")) .ok_or_else(|| anyhow::anyhow!("no matching version found"))
.cloned()?) .cloned()?)

View file

@ -109,11 +109,12 @@ pub struct CurseforgePackage {
pub summary: String, pub summary: String,
// pub status: u32, // pub status: u32,
pub download_count: u64, pub download_count: u64,
#[serde(default)]
pub is_featured: bool, pub is_featured: bool,
// pub primary_category_id: u32, // pub primary_category_id: u32,
pub categories: Vec<Category>, pub categories: Vec<Category>,
pub authors: Vec<Author>, pub authors: Vec<Author>,
pub logo: Logo, pub logo: Option<Logo>,
// pub screenshots: Vec<Screenshot>, // pub screenshots: Vec<Screenshot>,
// pub main_file_id: u32, // pub main_file_id: u32,
// pub latest_files: Vec<LatestFile>, // pub latest_files: Vec<LatestFile>,
@ -122,8 +123,8 @@ pub struct CurseforgePackage {
pub date_created: DateTime<Utc>, pub date_created: DateTime<Utc>,
pub date_modified: DateTime<Utc>, pub date_modified: DateTime<Utc>,
pub date_released: DateTime<Utc>, pub date_released: DateTime<Utc>,
#[serde(default)] // #[serde(default)]
pub allow_mod_distribution: bool, // pub allow_mod_distribution: bool,
// pub game_popularity_rank: u32, // pub game_popularity_rank: u32,
// pub is_available: bool, // pub is_available: bool,
pub thumbs_up_count: u32, pub thumbs_up_count: u32,
@ -143,7 +144,7 @@ impl From<CurseforgePackage> for ManagedPackage {
versions: vec![], versions: vec![],
game_versions: vec![], game_versions: vec![],
loaders: vec![], loaders: vec![],
icon_url: Some(package.logo.url), icon_url: package.logo.and_then(|l| Some(l.url)),
created: package.date_created, created: package.date_created,
updated: package.date_modified, updated: package.date_modified,
client: crate::store::PackageSide::Unknown, client: crate::store::PackageSide::Unknown,
@ -189,7 +190,7 @@ impl Into<SearchResult> for CurseforgePackage {
server_side: crate::store::PackageSide::Unknown, server_side: crate::store::PackageSide::Unknown,
project_type: self.package_type.into(), project_type: self.package_type.into(),
downloads: self.download_count, downloads: self.download_count,
icon_url: self.logo.url, icon_url: self.logo.map_or(String::new(), |l| l.url),
categories: vec![], // TODO categories: vec![], // TODO
display_categories: vec![], display_categories: vec![],
versions: vec![], versions: vec![],
@ -278,6 +279,7 @@ pub struct ModFile {
pub id: u32, pub id: u32,
pub game_id: u32, pub game_id: u32,
pub mod_id: u32, pub mod_id: u32,
#[serde(default)]
pub is_available: bool, pub is_available: bool,
pub display_name: String, pub display_name: String,
pub file_name: String, pub file_name: String,
@ -591,6 +593,25 @@ pub async fn get(id: u32) -> Result<CurseforgePackage> {
) )
} }
pub async fn get_multiple(ids: &[u32]) -> Result<Vec<CurseforgePackage>> {
let mut map = serde_json::Map::new();
map.insert("modIds".to_string(), ids.to_vec().into());
let json_body = serde_json::Value::Object(map);
Ok(
serde_json::from_slice::<CFData<_>>(
&fetch_advanced(
Method::POST,
"/v1/mods",
None::<fn(&mut Url)>,
None,
Some(json_body),
).await?)?
.data,
)
}
pub async fn get_package_body(url: String) -> Result<String> { pub async fn get_package_body(url: String) -> Result<String> {
Ok(serde_json::from_slice::<CFData<_>>(&fetch(&url).await?)?.data) Ok(serde_json::from_slice::<CFData<_>>(&fetch(&url).await?)?.data)
} }

View file

@ -108,13 +108,36 @@ impl Providers {
pub async fn get_multiple(&self, slug_or_ids: &[String]) -> Result<Vec<ManagedPackage>> { pub async fn get_multiple(&self, slug_or_ids: &[String]) -> Result<Vec<ManagedPackage>> {
Ok(match self { Ok(match self {
Self::Modrinth => modrinth::get_multiple(slug_or_ids), Self::Modrinth => {
Self::Curseforge => todo!(), if slug_or_ids.len() <= 0 {
} return Ok(vec![]);
.await? }
.into_iter()
.map(Into::into) modrinth::get_multiple(slug_or_ids)
.collect()) .await?
.into_iter()
.map(Into::into)
.collect()
},
Self::Curseforge => {
let parsed_ids = slug_or_ids
.iter()
.filter_map(|id| id.parse::<u32>().ok())
.collect::<Vec<u32>>();
if parsed_ids.len() <= 0 {
return Ok(vec![]);
}
let parsed_ids = parsed_ids.as_slice();
curseforge::get_multiple(parsed_ids)
.await?
.into_iter()
.map(Into::into)
.collect()
},
})
} }
pub async fn get_all_versions( pub async fn get_all_versions(