Merge pull request #203 from Polyfrost/feat/curseforge
Feature: Implement Curseforge Package API
This commit is contained in:
commit
3745ed03ec
34 changed files with 1681 additions and 467 deletions
3
.template.env
Normal file
3
.template.env
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#! Use single quotes due to special characters and dotenvy being weird
|
||||
|
||||
CURSEFORGE_API_KEY = ''
|
||||
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -1672,6 +1672,11 @@ version = "0.15.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
source = "git+https://github.com/allan2/dotenvy/?rev=db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5#db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.1"
|
||||
|
|
@ -4375,6 +4380,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"discord-rich-presence",
|
||||
"dotenvy 0.15.7 (git+https://github.com/allan2/dotenvy/?rev=db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5)",
|
||||
"flate2",
|
||||
"futures",
|
||||
"indicatif",
|
||||
|
|
@ -4395,6 +4401,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_ini",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"sha1_smol",
|
||||
"sha2 0.10.8",
|
||||
"specta 2.0.0-rc.20",
|
||||
|
|
@ -5170,7 +5177,7 @@ dependencies = [
|
|||
"bigdecimal 0.4.5",
|
||||
"chrono",
|
||||
"diagnostics",
|
||||
"dotenvy",
|
||||
"dotenvy 0.15.7 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"futures",
|
||||
"include_dir",
|
||||
"indexmap 1.9.3",
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ zeroize = { version = "1.8", features = [ "zeroize_derive" ] }
|
|||
serde = { version = "1.0", features = [ "derive" ] }
|
||||
serde_json = { version = "1.0" }
|
||||
serde_ini = { version = "0.2" }
|
||||
serde_repr = { version = "0.1" }
|
||||
regex = { version = "1.10" }
|
||||
url = { version = "2.5.2" }
|
||||
strum = { version = "0.26", features = [ "derive" ] }
|
||||
|
|
@ -179,6 +180,7 @@ cocoa = { version = "0.26" }
|
|||
objc = { version = "0.2" }
|
||||
cc = { version = "1.1" }
|
||||
libc = { version = "0.2" }
|
||||
dotenvy = { git = "https://github.com/allan2/dotenvy/", rev = "db0d6aa8fc8d1b0ddbfe94b5c833372e463eb1c5" }
|
||||
# https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/Cargo.toml#L102
|
||||
webkit2gtk = { version = "=2.0.1", features = [ "v2_40" ] }
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ macro_rules! collect_commands {
|
|||
get_provider_package_version,
|
||||
search_provider_packages,
|
||||
get_provider_authors,
|
||||
get_package_body,
|
||||
download_provider_package,
|
||||
// Cluster Packages
|
||||
get_cluster_package,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ use onelauncher::cluster::content::package;
|
|||
use onelauncher::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
|
||||
use onelauncher::package::content::Providers;
|
||||
use onelauncher::package::import::{default_launcher_path, ImportType};
|
||||
use onelauncher::store::{Author, ClusterPath, Package, PackagePath, ProviderSearchResults};
|
||||
use onelauncher::store::{
|
||||
Author, ClusterPath, Package, PackageBody, PackagePath, ProviderSearchResults,
|
||||
};
|
||||
use onelauncher::utils::pagination::Pagination;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[specta::specta]
|
||||
|
|
@ -49,9 +52,11 @@ pub async fn get_all_provider_package_versions(
|
|||
project_id: String,
|
||||
game_versions: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>,
|
||||
) -> Result<Vec<ManagedVersion>, String> {
|
||||
page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
) -> Result<(Vec<ManagedVersion>, Pagination), String> {
|
||||
Ok(provider
|
||||
.get_all_versions(&project_id, game_versions, loaders)
|
||||
.get_all_versions(&project_id, game_versions, loaders, page, page_size)
|
||||
.await?)
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +75,7 @@ pub async fn get_provider_package_version(
|
|||
provider: Providers,
|
||||
version: String,
|
||||
) -> Result<ManagedVersion, String> {
|
||||
Ok(provider.get_version(&version).await?)
|
||||
Ok(provider.get_version("", &version).await?) // TODO
|
||||
}
|
||||
|
||||
#[derive(specta::Type, serde::Deserialize, serde::Serialize)]
|
||||
|
|
@ -114,6 +119,12 @@ pub async fn get_provider_authors(
|
|||
Ok(provider.get_authors(&author).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn get_package_body(provider: Providers, body: PackageBody) -> Result<String, String> {
|
||||
Ok(provider.get_package_body(&body).await?)
|
||||
}
|
||||
|
||||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub async fn download_provider_package(
|
||||
|
|
@ -138,6 +149,7 @@ pub async fn download_provider_package(
|
|||
package_version,
|
||||
)
|
||||
.await?;
|
||||
|
||||
package::add_package(
|
||||
&cluster.cluster_path(),
|
||||
pkg_path,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ interface SearchResultsContainerProps {
|
|||
provider: Providers;
|
||||
results: SearchResult[];
|
||||
header: string | JSX.Element;
|
||||
category: string;
|
||||
collapsable?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +55,7 @@ function SearchResultsContainer(props: SearchResultsContainerProps) {
|
|||
</div>
|
||||
|
||||
<Show when={collapsed() === true}>
|
||||
<div class="absolute bottom-4 left-1/2 z-2 -translate-x-1/2">
|
||||
<div class="w-full flex items-center justify-center">
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
children="Show more"
|
||||
|
|
@ -146,7 +145,7 @@ function PackageItem(props: SearchResult & { provider: Providers; row?: boolean
|
|||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 p-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-fg-primary font-medium">{props.title}</h4>
|
||||
<h4 class="text-fg-primary font-medium line-height-normal">{props.title}</h4>
|
||||
<p class="text-xs text-fg-secondary">
|
||||
By
|
||||
{' '}
|
||||
|
|
@ -166,10 +165,12 @@ function PackageItem(props: SearchResult & { provider: Providers; row?: boolean
|
|||
{abbreviateNumber(props.downloads)}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
{abbreviateNumber(props.follows)}
|
||||
</div>
|
||||
<Show when={props.follows > 0}>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
{abbreviateNumber(props.follows)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, ProviderSearchResults } from '@onelauncher/client/bindings';
|
||||
import type { ModalProps } from '~ui/components/overlay/Modal';
|
||||
import type { Cluster, ManagedPackage, PackageType, Providers, ProviderSearchQuery, SearchResult } from '@onelauncher/client/bindings';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { bridge } from '~imports';
|
||||
import Button from '~ui/components/base/Button';
|
||||
import Dropdown from '~ui/components/base/Dropdown';
|
||||
import Modal, { createModal } from '~ui/components/overlay/Modal';
|
||||
import { createModal } from '~ui/components/overlay/Modal';
|
||||
import BrowserPackage from '~ui/pages/browser/BrowserPackage';
|
||||
import { type Accessor, type Context, createContext, createEffect, createSignal, For, on, onMount, type ParentProps, type Setter, Show, untrack, useContext } from 'solid-js';
|
||||
import { useRecentCluster } from './useCluster';
|
||||
import useCommand, { tryResult } from './useCommand';
|
||||
import { PROVIDERS } from '~utils';
|
||||
import { type Accessor, type Context, createContext, createEffect, createSignal, on, onMount, type ParentProps, type Setter, useContext } from 'solid-js';
|
||||
import { ChooseClusterModal, useRecentCluster } from './useCluster';
|
||||
import { tryResult } from './useCommand';
|
||||
|
||||
export type ProviderSearchOptions = ProviderSearchQuery & { provider: Providers };
|
||||
export type PopularPackages = Record<Providers, SearchResult[]>;
|
||||
|
||||
interface BrowserControllerType {
|
||||
cluster: Accessor<Cluster | undefined>;
|
||||
|
|
@ -18,7 +17,6 @@ interface BrowserControllerType {
|
|||
|
||||
displayBrowser: (cluster?: Cluster | undefined) => void;
|
||||
displayPackage: (id: string, provider: Providers) => void;
|
||||
displayCategory: (category: string) => void;
|
||||
displayClusterSelector: () => void;
|
||||
|
||||
search: () => void;
|
||||
|
|
@ -28,16 +26,15 @@ interface BrowserControllerType {
|
|||
packageType: Accessor<PackageType>;
|
||||
setPackageType: Setter<PackageType>;
|
||||
|
||||
refreshCache: () => void;
|
||||
cache: Accessor<ProviderSearchResults | undefined>;
|
||||
featured: Accessor<ManagedPackage | undefined>;
|
||||
popularPackages: Accessor<PopularPackages | undefined>;
|
||||
featuredPackage: Accessor<ManagedPackage | undefined>;
|
||||
};
|
||||
|
||||
const BrowserContext = createContext() as Context<BrowserControllerType>;
|
||||
|
||||
export function BrowserProvider(props: ParentProps) {
|
||||
const [mainPageCache, setMainPageCache] = createSignal<ProviderSearchResults>();
|
||||
const [featured, setFeatured] = createSignal<ManagedPackage | undefined>();
|
||||
const [popularPackages, setPopularPackages] = createSignal<PopularPackages | undefined>();
|
||||
const [featuredPackage, setFeaturedPackage] = createSignal<ManagedPackage | undefined>();
|
||||
|
||||
// Used for the current "Browser Mode". It'll only show packages of the selected type
|
||||
const [packageType, setPackageType] = createSignal<PackageType>('mod');
|
||||
|
|
@ -48,7 +45,7 @@ export function BrowserProvider(props: ParentProps) {
|
|||
const [searchOptions, setSearchOptions] = createSignal<ProviderSearchOptions>({
|
||||
provider: 'Modrinth',
|
||||
query: '',
|
||||
limit: 20,
|
||||
limit: 18, // Even multiples of 3 recommended for grid view (normally 3 columns, 2 columns on very small windows)
|
||||
offset: 0,
|
||||
categories: null,
|
||||
game_versions: null,
|
||||
|
|
@ -60,8 +57,30 @@ export function BrowserProvider(props: ParentProps) {
|
|||
const navigate = useNavigate();
|
||||
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 => (
|
||||
<ChooseClusterModal
|
||||
onSelected={chooseCluster}
|
||||
selected={list => currentlySelectedCluster(list) || 0}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
@ -78,45 +97,55 @@ export function BrowserProvider(props: ParentProps) {
|
|||
navigate(BrowserPackage.buildUrl({ id, provider }));
|
||||
},
|
||||
|
||||
displayCategory(_category: string) {
|
||||
|
||||
},
|
||||
|
||||
displayClusterSelector() {
|
||||
modal.show();
|
||||
},
|
||||
|
||||
search: () => {
|
||||
navigate('/browser/search');
|
||||
},
|
||||
search: () => navigate('/browser/search'),
|
||||
searchQuery: searchOptions,
|
||||
setSearchQuery: setSearchOptions,
|
||||
|
||||
packageType,
|
||||
setPackageType,
|
||||
|
||||
async refreshCache() {
|
||||
const opts = untrack(searchOptions);
|
||||
const res = await tryResult(() => bridge.commands.searchProviderPackages('Modrinth', opts));
|
||||
|
||||
setMainPageCache(res);
|
||||
|
||||
// TODO: Better algorithm for selecting a featured package
|
||||
const firstPackage = res.results[0];
|
||||
if (firstPackage !== undefined) {
|
||||
const featuredPackage = await tryResult(() => bridge.commands.getProviderPackage('Modrinth', firstPackage.project_id));
|
||||
setFeatured(featuredPackage);
|
||||
}
|
||||
},
|
||||
|
||||
cache: mainPageCache,
|
||||
|
||||
featured,
|
||||
popularPackages,
|
||||
featuredPackage,
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (mainPageCache() === undefined)
|
||||
controller.refreshCache();
|
||||
onMount(async () => {
|
||||
const getOpts = (provider: Providers): ProviderSearchOptions => ({
|
||||
provider,
|
||||
query: '',
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
categories: null,
|
||||
game_versions: null,
|
||||
loaders: null,
|
||||
package_types: ['mod'],
|
||||
open_source: null,
|
||||
});
|
||||
|
||||
const response = await Promise.allSettled(
|
||||
PROVIDERS.map(provider => tryResult(() => bridge.commands.searchProviderPackages(provider, getOpts(provider)))),
|
||||
);
|
||||
|
||||
const popularPackages = response.reduce((acc, res, i) => {
|
||||
const provider = PROVIDERS[i] as Providers;
|
||||
|
||||
if (res.status === 'fulfilled')
|
||||
acc[provider] = res.value.results;
|
||||
|
||||
return acc;
|
||||
}, {} as PopularPackages);
|
||||
|
||||
setPopularPackages(popularPackages);
|
||||
|
||||
// TODO: Better algorithm for selecting a featured package
|
||||
const firstPackage = popularPackages.Modrinth[0] || popularPackages.Curseforge[0];
|
||||
if (firstPackage !== undefined) {
|
||||
const featuredPackage = await tryResult(() => bridge.commands.getProviderPackage('Modrinth', firstPackage.project_id));
|
||||
setFeaturedPackage(featuredPackage);
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -153,73 +182,3 @@ export default function useBrowser() {
|
|||
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import type { Cluster } from '@onelauncher/client/bindings';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
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 { 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';
|
||||
|
||||
const ClusterContext = createContext() as Context<ResourceReturn<Cluster>>;
|
||||
|
|
@ -104,3 +106,82 @@ export function useRecentCluster() {
|
|||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ function usePagination(initialOptions: PaginationOptions) {
|
|||
totalPages,
|
||||
offset,
|
||||
reset,
|
||||
options,
|
||||
Navigation,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { ManagedPackage } from '@onelauncher/client/bindings';
|
||||
import type { ManagedPackage, Providers } from '@onelauncher/client/bindings';
|
||||
import { ChevronRightIcon } from '@untitled-theme/icons-solid';
|
||||
import OneConfigLogo from '~assets/logos/oneconfig.svg?component-solid';
|
||||
import Button from '~ui/components/base/Button';
|
||||
import SearchResultsContainer from '~ui/components/content/SearchResults';
|
||||
import useBrowser from '~ui/hooks/useBrowser';
|
||||
import { onMount, Show } from 'solid-js';
|
||||
import { For, onMount, Show } from 'solid-js';
|
||||
import { BrowserContent } from './BrowserRoot';
|
||||
|
||||
function BrowserMain() {
|
||||
|
|
@ -20,15 +20,19 @@ function BrowserMain() {
|
|||
return (
|
||||
<BrowserContent>
|
||||
<div class="flex flex-col gap-8">
|
||||
<Show when={browser.cache() !== undefined && browser.featured() !== undefined}>
|
||||
<Featured package={browser.featured()!} />
|
||||
<Show when={browser.popularPackages() !== undefined && browser.featuredPackage() !== undefined}>
|
||||
<Featured package={browser.featuredPackage()!} />
|
||||
|
||||
<SearchResultsContainer
|
||||
category="modrinth"
|
||||
collapsable
|
||||
header="Modrinth"
|
||||
{...browser.cache()!}
|
||||
/>
|
||||
<For each={Object.entries(browser.popularPackages()!)}>
|
||||
{([provider, results]) => (
|
||||
<SearchResultsContainer
|
||||
collapsable
|
||||
header={provider}
|
||||
provider={provider as Providers}
|
||||
results={results}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</BrowserContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Cluster, ManagedPackage, ManagedUser, ManagedVersion, Providers } from '@onelauncher/client/bindings';
|
||||
import type { Cluster, ManagedPackage, ManagedVersion, Providers } from '@onelauncher/client/bindings';
|
||||
import { A, type Params, Route, useSearchParams } from '@solidjs/router';
|
||||
import { CalendarIcon, ChevronDownIcon, ClockRewindIcon, Download01Icon, File02Icon, HeartIcon, LinkExternal01Icon } from '@untitled-theme/icons-solid';
|
||||
import SteveHead from '~assets/images/steve.png';
|
||||
|
|
@ -9,13 +9,15 @@ import Dropdown from '~ui/components/base/Dropdown';
|
|||
import Link from '~ui/components/base/Link';
|
||||
import Tooltip from '~ui/components/base/Tooltip';
|
||||
import Markdown from '~ui/components/content/Markdown';
|
||||
import { createModal } from '~ui/components/overlay/Modal';
|
||||
import Spinner from '~ui/components/Spinner';
|
||||
import useBrowser from '~ui/hooks/useBrowser';
|
||||
import useCommand from '~ui/hooks/useCommand';
|
||||
import { ChooseClusterModal } from '~ui/hooks/useCluster';
|
||||
import useCommand, { tryResult } from '~ui/hooks/useCommand';
|
||||
import usePagination from '~ui/hooks/usePagination';
|
||||
import usePromptOpener from '~ui/hooks/usePromptOpener';
|
||||
import { abbreviateNumber, formatAsRelative } from '~utils';
|
||||
import { createContext, createEffect, createSignal, For, Match, on, type ParentProps, 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';
|
||||
|
||||
interface BrowserModParams extends Params {
|
||||
|
|
@ -23,14 +25,25 @@ interface BrowserModParams extends Params {
|
|||
provider: Providers;
|
||||
}
|
||||
|
||||
const BrowserPackageContext = createContext<ManagedPackage | null>(null);
|
||||
interface BrowserPackageContextType {
|
||||
pkg: Accessor<ManagedPackage>;
|
||||
body: Resource<string>;
|
||||
};
|
||||
|
||||
const BrowserPackageContext = createContext<BrowserPackageContextType | null>(null);
|
||||
|
||||
const basePath = '/browser/package';
|
||||
|
||||
function BrowserPackageProvider(props: ParentProps & { pkg: ManagedPackage }) {
|
||||
const [body] = useCommand(() => bridge.commands.getPackageBody(props.pkg.provider, props.pkg.body));
|
||||
|
||||
const ctx: BrowserPackageContextType = {
|
||||
pkg: () => props.pkg,
|
||||
body,
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line solid/reactivity -- Should be fine
|
||||
<BrowserPackageContext.Provider value={props.pkg}>
|
||||
<BrowserPackageContext.Provider value={ctx}>
|
||||
{props.children}
|
||||
</BrowserPackageContext.Provider>
|
||||
);
|
||||
|
|
@ -67,7 +80,6 @@ function NavLink(props: ParentProps & { href: string }) {
|
|||
function BrowserPackage(props: ParentProps) {
|
||||
const [params] = useSearchParams<BrowserModParams>();
|
||||
const [pkg] = useCommand(() => bridge.commands.getProviderPackage(params.provider!, params.id!));
|
||||
const [authors] = useCommand(pkg, () => bridge.commands.getProviderAuthors(pkg()!.provider, pkg()!.author));
|
||||
|
||||
const links = [
|
||||
['About', '/'],
|
||||
|
|
@ -81,7 +93,7 @@ function BrowserPackage(props: ParentProps) {
|
|||
<Show
|
||||
children={(
|
||||
<>
|
||||
<BrowserSidebar authors={authors()!} package={pkg()!} />
|
||||
<BrowserSidebar package={pkg()!} />
|
||||
|
||||
<div class="min-h-full flex flex-1 flex-col items-start gap-y-4 pb-8">
|
||||
<div class="flex flex-none flex-row gap-x-1 rounded-lg bg-component-bg p-1">
|
||||
|
|
@ -108,7 +120,7 @@ function BrowserPackage(props: ParentProps) {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
when={pkg() !== undefined && authors() !== undefined}
|
||||
when={pkg() !== undefined}
|
||||
/>
|
||||
</div>
|
||||
</Spinner.Suspense>
|
||||
|
|
@ -123,7 +135,8 @@ BrowserPackage.Routes = BrowserPackageRoutes;
|
|||
|
||||
export default BrowserPackage;
|
||||
|
||||
function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[] }) {
|
||||
function BrowserSidebar(props: { package: ManagedPackage }) {
|
||||
const [authors] = useCommand(() => props.package, () => bridge.commands.getProviderAuthors(props.package.provider, props.package.author));
|
||||
const createdAt = () => new Date(props.package.created);
|
||||
const updatedAt = () => new Date(props.package.updated);
|
||||
const promptOpen = usePromptOpener();
|
||||
|
|
@ -137,7 +150,7 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
|
|||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 p-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-fg-primary font-medium">{props.package.title}</h4>
|
||||
<h4 class="text-fg-primary font-medium line-height-snug">{props.package.title}</h4>
|
||||
<p class="text-xs text-fg-secondary">
|
||||
<span class="text-fg-primary capitalize">{props.package.package_type}</span>
|
||||
{' '}
|
||||
|
|
@ -158,10 +171,12 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
|
|||
{abbreviateNumber(props.package.downloads)}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
{abbreviateNumber(props.package.followers)}
|
||||
</div>
|
||||
<Show when={props.package.followers > 0}>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
{abbreviateNumber(props.package.followers)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -170,7 +185,7 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
|
|||
|
||||
<div class="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
|
||||
<h4 class="text-fg-primary font-bold">Links</h4>
|
||||
<Link href={getPackageUrl(props.package.provider, props.package.id, props.package.package_type)} includeIcon>
|
||||
<Link href={getPackageUrl(props.package)} includeIcon>
|
||||
{props.package.provider}
|
||||
{' '}
|
||||
Page
|
||||
|
|
@ -179,14 +194,14 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
|
|||
|
||||
<div class="flex flex-col gap-2 rounded-lg bg-component-bg p-3">
|
||||
<h4 class="text-fg-primary font-bold">Authors</h4>
|
||||
<For each={props.authors}>
|
||||
<For each={authors()}>
|
||||
{author => (
|
||||
<>
|
||||
<div
|
||||
class="flex flex-row items-center gap-x-1 rounded-md p-1 active:bg-component-bg-pressed hover:bg-component-bg-hover"
|
||||
onClick={() => promptOpen(author.url)}
|
||||
>
|
||||
<img alt={`${author.username}'s avatar`} class="h-8 min-h-8 min-w-8 w-8 rounded-md" src={author.avatar_url || SteveHead} />
|
||||
<img alt={`${author.username}'s avatar`} class="h-8 min-h-8 min-w-8 w-8 rounded-[5px]" src={author.avatar_url || SteveHead} />
|
||||
<div class="flex flex-1 flex-col justify-center gap-y-1">
|
||||
<span>{author.username}</span>
|
||||
|
||||
|
|
@ -200,7 +215,7 @@ function BrowserSidebar(props: { package: ManagedPackage; authors: ManagedUser[]
|
|||
</div>
|
||||
<LinkExternal01Icon class="h-4 w-4" />
|
||||
</div>
|
||||
<Show when={author.is_organization_user === true && props.authors.length > 1}>
|
||||
<Show when={author.is_organization_user === true && ((authors()?.length || 0) > 1)}>
|
||||
<div class="h-px w-full bg-gray-05" />
|
||||
</Show>
|
||||
</>
|
||||
|
|
@ -257,7 +272,7 @@ function InstallButton(props: ManagedPackage) {
|
|||
return game_version && loader;
|
||||
};
|
||||
|
||||
const filtered = () => clusters()?.filter(meetsRequirements);
|
||||
const filtered = createMemo(() => clusters()?.filter(meetsRequirements));
|
||||
|
||||
const getSelectedCluster = () => filtered()?.[selected()];
|
||||
|
||||
|
|
@ -299,44 +314,44 @@ function InstallButton(props: ManagedPackage) {
|
|||
});
|
||||
|
||||
return (
|
||||
<div class="h-12 flex flex-row">
|
||||
<Show when={(filtered()?.length || 0) > 0}>
|
||||
<div class="h-12 flex flex-row">
|
||||
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
children={(
|
||||
<div class="flex flex-1 flex-col items-center justify-center">
|
||||
<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>
|
||||
</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>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
children={(
|
||||
<div class="flex flex-1 flex-col items-center justify-center">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Dropdown>
|
||||
</div>
|
||||
class="max-w-full flex-1 rounded-r-none!"
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
dropdownClass="w-58! right-0"
|
||||
onChange={setSelected}
|
||||
selected={selected}
|
||||
>
|
||||
<For each={filtered()}>
|
||||
{cluster => (
|
||||
<Dropdown.Row>{cluster.meta.name}</Dropdown.Row>
|
||||
)}
|
||||
</For>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -346,7 +361,7 @@ function BrowserPackageBody() {
|
|||
|
||||
return (
|
||||
<div class="w-full flex-1 rounded-lg bg-component-bg p-4 px-6">
|
||||
<Markdown body={context?.body || ''} />
|
||||
<Markdown body={context?.body?.() || ''} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -370,8 +385,8 @@ function BrowserPackageVersions() {
|
|||
const context = useContext(BrowserPackageContext);
|
||||
const MAX_ITEMS_PER_PAGE = 20;
|
||||
|
||||
const { page, Navigation } = usePagination({
|
||||
itemsCount: () => context?.versions.length || 0,
|
||||
const { page, Navigation, reset, options } = usePagination({
|
||||
itemsCount: () => context?.pkg().versions.length || 0,
|
||||
itemsPerPage: () => MAX_ITEMS_PER_PAGE,
|
||||
});
|
||||
|
||||
|
|
@ -379,20 +394,32 @@ function BrowserPackageVersions() {
|
|||
if (context === null)
|
||||
return [];
|
||||
|
||||
const newestToOldest = context.versions.toReversed();
|
||||
const newestToOldest = context.pkg().versions.toReversed();
|
||||
const start = (page - 1) * MAX_ITEMS_PER_PAGE;
|
||||
const end = start + MAX_ITEMS_PER_PAGE;
|
||||
|
||||
return newestToOldest.slice(start, end);
|
||||
};
|
||||
|
||||
const [versions, { refetch }] = useCommand(context, async () => {
|
||||
const [versions, { refetch }] = createResource(context, async () => {
|
||||
if (context === null)
|
||||
return { error: 'No package context', status: 'error' };
|
||||
throw new Error('Context is null');
|
||||
|
||||
if (context.pkg().versions.length === 0) {
|
||||
const [data, pagination] = (await tryResult(() => bridge.commands.getAllProviderPackageVersions(context.pkg().provider, context.pkg().id, null, null, page() - 1, MAX_ITEMS_PER_PAGE))) || [];
|
||||
|
||||
if (options().itemsCount() !== pagination.totalCount)
|
||||
reset({
|
||||
itemsCount: () => pagination.totalCount || 0,
|
||||
itemsPerPage: () => MAX_ITEMS_PER_PAGE,
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
const list = getVersionsForPage(page());
|
||||
|
||||
return await bridge.commands.getProviderPackageVersions(context.provider, list);
|
||||
return (await tryResult(() => bridge.commands.getProviderPackageVersions(context.pkg().provider, list))).toReversed();
|
||||
});
|
||||
|
||||
let container!: HTMLDivElement;
|
||||
|
|
@ -430,7 +457,7 @@ function BrowserPackageVersions() {
|
|||
</thead>
|
||||
|
||||
<tbody>
|
||||
<For each={versions()?.reverse()}>
|
||||
<For each={versions() || []}>
|
||||
{version => <VersionRow {...version} />}
|
||||
</For>
|
||||
</tbody>
|
||||
|
|
@ -444,7 +471,7 @@ function BrowserPackageVersions() {
|
|||
}
|
||||
|
||||
function colorForType(type: string) {
|
||||
switch (type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'release':
|
||||
return 'bg-code-trace';
|
||||
case 'snapshot':
|
||||
|
|
@ -459,6 +486,59 @@ function colorForType(type: string) {
|
|||
}
|
||||
|
||||
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 (
|
||||
<tr class="my-2 bg-page-elevated px-4 [&>td]:py-4">
|
||||
<td class="rounded-l-lg px-4">
|
||||
|
|
@ -471,8 +551,8 @@ function VersionRow(props: ManagedVersion) {
|
|||
|
||||
<td>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-lg">{props.version_id}</h3>
|
||||
<p class="text-wrap text-sm">{props.name}</p>
|
||||
<h3 class="text-lg">{props.version_display}</h3>
|
||||
<p class="text-wrap text-sm">{props.name === props.version_display ? props.files[0]?.file_name : props.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
|
@ -508,6 +588,7 @@ function VersionRow(props: ManagedVersion) {
|
|||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
children={<Download01Icon />}
|
||||
onClick={downloadPrompted}
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
|
|
|||
|
|
@ -93,10 +93,10 @@ function BrowserCategories() {
|
|||
<div class="flex flex-col gap-y-6">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<h6 class="my-1">Categories</h6>
|
||||
<For each={browserCategories.byPackageType(browser.packageType())}>
|
||||
<For each={browserCategories.byPackageType(browser.packageType(), browser.searchQuery().provider)}>
|
||||
{category => (
|
||||
<p
|
||||
class={`text-md capitalize text-fg-primary hover:text-fg-primary-hover ${isEnabled(category.id) ? 'text-opacity-100! hover:text-opacity-90!' : 'text-opacity-60! hover:text-opacity-70!'}`}
|
||||
class={`text-md capitalize text-fg-primary hover:text-fg-primary-hover line-height-snug ${isEnabled(category.id) ? 'text-opacity-100! hover:text-opacity-90!' : 'text-opacity-60! hover:text-opacity-70!'}`}
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
>
|
||||
{category.display}
|
||||
|
|
@ -127,10 +127,13 @@ function BrowserSidebar() {
|
|||
<div class="flex flex-col gap-y-1">
|
||||
<h6 class="my-1">Provider</h6>
|
||||
<Dropdown
|
||||
onChange={index => controller.setSearchQuery(prev => ({
|
||||
...prev,
|
||||
provider: PROVIDERS[index] || 'Modrinth',
|
||||
}))}
|
||||
onChange={(index) => {
|
||||
controller.setSearchQuery(prev => ({
|
||||
...prev,
|
||||
provider: PROVIDERS[index] || 'Modrinth',
|
||||
}));
|
||||
controller.search();
|
||||
}}
|
||||
selected={() => PROVIDERS.indexOf(controller.searchQuery().provider)}
|
||||
>
|
||||
<For each={PROVIDERS}>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { BrowserContent } from './BrowserRoot';
|
|||
|
||||
function BrowserSearch() {
|
||||
const browser = useBrowser();
|
||||
const [results, { refetch }] = useCommand(() => bridge.commands.searchProviderPackages('Modrinth', browser.searchQuery()));
|
||||
const [results, { refetch }] = useCommand(() => bridge.commands.searchProviderPackages(browser.searchQuery().provider, browser.searchQuery()));
|
||||
|
||||
const paginationOptions = (): PaginationOptions => ({
|
||||
itemsCount: () => results()?.total || 0,
|
||||
|
|
@ -39,14 +39,13 @@ function BrowserSearch() {
|
|||
<Spinner.Suspense>
|
||||
<Show when={results() !== undefined}>
|
||||
<SearchResultsContainer
|
||||
category="test"
|
||||
header={(
|
||||
<div class="w-full flex flex-row items-end justify-between">
|
||||
<h5 class="ml-2">Results</h5>
|
||||
<pagination.Navigation />
|
||||
</div>
|
||||
)}
|
||||
provider="Modrinth"
|
||||
provider={browser.searchQuery().provider}
|
||||
results={results()!.results}
|
||||
/>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import Button from '~ui/components/base/Button';
|
|||
import TextField from '~ui/components/base/TextField';
|
||||
import ScrollableContainer from '~ui/components/ScrollableContainer';
|
||||
import Sidebar from '~ui/components/Sidebar';
|
||||
import Spinner from '~ui/components/Spinner';
|
||||
import useBrowser from '~ui/hooks/useBrowser';
|
||||
import useClusterContext from '~ui/hooks/useCluster';
|
||||
import useCommand, { tryResult } from '~ui/hooks/useCommand';
|
||||
|
|
@ -39,19 +38,10 @@ function ClusterMods() {
|
|||
}
|
||||
|
||||
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
|
||||
const [displayedMods, setDisplayedMods] = createSignal<Package[]>([]);
|
||||
|
||||
|
|
@ -125,24 +115,19 @@ function ClusterMods() {
|
|||
</div>
|
||||
|
||||
<ScrollableContainer>
|
||||
<Spinner.Suspense>
|
||||
<Show when={managedMods.loading === false}>
|
||||
<Switch>
|
||||
<Match when={displayedMods().length > 0}>
|
||||
<For each={displayedMods()}>
|
||||
{(mod) => {
|
||||
const managed = getManagedData(mod);
|
||||
return <ModEntry managed={managed} pkg={mod} refetch={refetch} />;
|
||||
}}
|
||||
</For>
|
||||
</Match>
|
||||
<Switch>
|
||||
<Match when={displayedMods().length > 0}>
|
||||
<For each={displayedMods()}>
|
||||
{mod => (
|
||||
<ModEntry managedMods={managedMods()} pkg={mod} refetch={refetch} />
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
|
||||
<Match when={displayedMods().length === 0}>
|
||||
<p class="my-4 text-center text-2lg">No mods were found</p>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</Spinner.Suspense>
|
||||
<Match when={displayedMods().length === 0}>
|
||||
<p class="my-4 text-center text-2lg">No mods were found</p>
|
||||
</Match>
|
||||
</Switch>
|
||||
</ScrollableContainer>
|
||||
</Sidebar.Page>
|
||||
);
|
||||
|
|
@ -153,13 +138,23 @@ export default ClusterMods;
|
|||
interface ModEntryProps {
|
||||
pkg: Package;
|
||||
refetch: () => void;
|
||||
managed?: ManagedPackage | undefined;
|
||||
managedMods?: Map<Providers, ManagedPackage[]>;
|
||||
};
|
||||
|
||||
function ModEntry(props: ModEntryProps) {
|
||||
const [cluster] = useClusterContext();
|
||||
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 = () => {
|
||||
if (props.pkg.meta.type === 'unknown')
|
||||
return props.pkg.file_name;
|
||||
|
|
@ -191,17 +186,19 @@ function ModEntry(props: ModEntryProps) {
|
|||
|
||||
const icon = () => {
|
||||
if (props.pkg.meta.type === 'managed')
|
||||
return props.managed?.icon_url;
|
||||
return managed()?.icon_url;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (props.managed) {
|
||||
const mngd = managed();
|
||||
|
||||
if (mngd) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
browser.displayPackage(props.managed.id, props.managed.provider);
|
||||
browser.displayPackage(mngd.id, mngd.provider);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,74 +7,11 @@ export interface CategoryItem {
|
|||
id: string;
|
||||
}
|
||||
|
||||
type ModCategories = [
|
||||
'adventure',
|
||||
'library',
|
||||
'equipment',
|
||||
'decoration',
|
||||
'food',
|
||||
'magic',
|
||||
'performance',
|
||||
'storage',
|
||||
'technology',
|
||||
'utility',
|
||||
'worldgen',
|
||||
];
|
||||
|
||||
const modMapping: Record<Providers, { [key in ModCategories[number]]: CategoryItem }> = {
|
||||
Modrinth: {
|
||||
adventure: {
|
||||
display: 'Adventure',
|
||||
id: 'adventure',
|
||||
},
|
||||
library: {
|
||||
display: 'Library',
|
||||
id: 'library',
|
||||
},
|
||||
decoration: {
|
||||
display: 'Decoration',
|
||||
id: 'decoration',
|
||||
},
|
||||
equipment: {
|
||||
display: 'Equipment',
|
||||
id: 'equipment',
|
||||
},
|
||||
food: {
|
||||
display: 'Food',
|
||||
id: 'food',
|
||||
},
|
||||
magic: {
|
||||
display: 'Magic',
|
||||
id: 'magic',
|
||||
},
|
||||
performance: {
|
||||
display: 'Performance',
|
||||
id: 'optimization',
|
||||
},
|
||||
storage: {
|
||||
display: 'Storage',
|
||||
id: 'storage',
|
||||
},
|
||||
technology: {
|
||||
display: 'Technology',
|
||||
id: 'technology',
|
||||
},
|
||||
utility: {
|
||||
display: 'Utility',
|
||||
id: 'utility',
|
||||
},
|
||||
worldgen: {
|
||||
display: 'World Generation',
|
||||
id: 'worldgen',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const browserCategories = {
|
||||
byPackageType(packageType: PackageType): CategoryItem[] {
|
||||
byPackageType(packageType: PackageType, provider: Providers = 'Modrinth'): CategoryItem[] {
|
||||
switch (packageType) {
|
||||
case 'mod':
|
||||
return this.mod();
|
||||
return this.mod(provider);
|
||||
}
|
||||
|
||||
return [];
|
||||
|
|
@ -84,3 +21,76 @@ export const browserCategories = {
|
|||
return Object.values(modMapping[provider]);
|
||||
},
|
||||
};
|
||||
|
||||
const modMapping: Record<Providers, CategoryItem[]> = {
|
||||
Curseforge: [
|
||||
{ display: 'Adventure', id: '422' },
|
||||
{ display: 'API and Library', id: '421' },
|
||||
{ display: 'Armor and Tools', id: '434' },
|
||||
{ display: 'Biomes', id: '407' },
|
||||
{ display: 'Bug Fixes', id: '6821' },
|
||||
{ display: 'Cosmetic', id: '424' },
|
||||
{ display: 'Dimensions', id: '410' },
|
||||
{ display: 'Education', id: '5299' },
|
||||
{ display: 'Energy', id: '417' },
|
||||
{ display: 'Farming', id: '416' },
|
||||
{ display: 'Food', id: '436' },
|
||||
{ display: 'Forestry', id: '433' },
|
||||
{ display: 'Magic', id: '419' },
|
||||
{ display: 'Miscellaneous', id: '425' },
|
||||
{ display: 'Mobs', id: '411' },
|
||||
{ display: 'Ores and Resources', id: '408' },
|
||||
{ display: 'Performance', id: '6814' },
|
||||
{ display: 'Player Transport', id: '414' },
|
||||
{ display: 'Redstone', id: '4558' },
|
||||
{ display: 'Storage', id: '420' },
|
||||
{ display: 'Structures', id: '409' },
|
||||
{ display: 'Technology', id: '412' },
|
||||
{ display: 'Utility & QoL', id: '5191' },
|
||||
{ display: 'World Generation', id: '406' },
|
||||
|
||||
// { display: 'Map and Information', id: '423' },
|
||||
// { display: 'Server Utility', id: '435' },
|
||||
// { display: 'Twitch Integration', id: '4671' },
|
||||
// { display: 'Create', id: '6484' },
|
||||
// { display: 'Buildcraft', id: '432' },
|
||||
// { display: 'Industrial Craft', id: '429' },
|
||||
// { display: 'KubeJS', id: '5314' },
|
||||
// { display: 'Genetics', id: '418' },
|
||||
// { display: 'Blood Magic', id: '4485' },
|
||||
// { display: 'Automation', id: '4843' },
|
||||
// { display: 'CraftTweaker', id: '4773' },
|
||||
// { display: 'Addons', id: '426' },
|
||||
// { display: 'Energy, Fluid, and Item Transport', id: '415' },
|
||||
// { display: 'Thermal Expansion', id: '427' },
|
||||
// { display: 'Thaumcraft', id: '430' },
|
||||
// { display: 'Processing', id: '413' },
|
||||
// { display: 'Tinker\'s Construct', id: '428' },
|
||||
// { display: 'Skyblock', id: '6145' },
|
||||
// { display: 'Galacticraft', id: '5232' },
|
||||
// { display: 'Applied Energistics 2', id: '4545' },
|
||||
// { display: 'Integrated Dynamics', id: '6954' },
|
||||
// { display: 'MCreator', id: '4906' },
|
||||
],
|
||||
Modrinth: [
|
||||
{ display: 'Adventure', id: 'adventure' },
|
||||
{ display: 'Cursed', id: 'cursed' },
|
||||
{ display: 'Decoration', id: 'decoration' },
|
||||
{ display: 'Economy', id: 'economy' },
|
||||
{ display: 'Equipment', id: 'equipment' },
|
||||
{ display: 'Food', id: 'food' },
|
||||
{ display: 'Game Mechanics', id: 'game-mechanics' },
|
||||
{ display: 'Library', id: 'library' },
|
||||
{ display: 'Magic', id: 'magic' },
|
||||
{ display: 'Management', id: 'management' },
|
||||
{ display: 'Minigame', id: 'minigame' },
|
||||
{ display: 'Mobs', id: 'mobs' },
|
||||
{ display: 'Optimization', id: 'optimization' },
|
||||
{ display: 'Social', id: 'social' },
|
||||
{ display: 'Storage', id: 'storage' },
|
||||
{ display: 'Technology', id: 'technology' },
|
||||
{ display: 'Transportation', id: 'transportation' },
|
||||
{ display: 'Utility', id: 'utility' },
|
||||
{ display: 'World Generation', id: 'worldgen' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Cluster, ImportType, License, Loader, PackageType, Providers, VersionType } from '@onelauncher/client/bindings';
|
||||
import type { Cluster, ImportType, License, Loader, ManagedPackage, PackageType, Providers, VersionType } from '@onelauncher/client/bindings';
|
||||
import { DurationFormat } from '@formatjs/intl-durationformat';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
|
||||
|
|
@ -160,15 +160,26 @@ export function getLicenseUrl(licenseId: string | License | null | undefined): s
|
|||
return `https://spdx.org/licenses/${id}.html`;
|
||||
}
|
||||
|
||||
export function getPackageUrl(provider: Providers, id: string, package_type: PackageType = 'mod'): string {
|
||||
const mapping: Record<Providers, string> = {
|
||||
Modrinth: `https://modrinth.com/${package_type}/${id}`,
|
||||
export function getPackageUrl(pkg: ManagedPackage): string {
|
||||
const mapping: Record<Providers, () => string> = {
|
||||
Modrinth: () => `https://modrinth.com/${pkg.package_type}/${pkg.id}`,
|
||||
Curseforge: () => {
|
||||
const packageTypeMapping: Record<PackageType, string> = {
|
||||
mod: 'mc-mods',
|
||||
shaderpack: 'shaders',
|
||||
resourcepack: 'texture-packs',
|
||||
datapack: 'data-packs',
|
||||
modpack: 'modpacks',
|
||||
};
|
||||
|
||||
return `https://www.curseforge.com/minecraft/${packageTypeMapping[pkg.package_type]}/${pkg.main}`;
|
||||
},
|
||||
};
|
||||
|
||||
return mapping[provider];
|
||||
return mapping[pkg.provider]();
|
||||
}
|
||||
|
||||
export const LOADERS: Loader[] = ['vanilla', 'fabric', 'forge', 'neoforge', 'quilt'] as const;
|
||||
export const PROVIDERS: Providers[] = ['Modrinth'] as const;
|
||||
export const PROVIDERS: Providers[] = ['Modrinth', 'Curseforge'] as const;
|
||||
export const PACKAGE_TYPES: PackageType[] = ['mod', 'resourcepack', 'datapack', 'shaderpack'] as const;
|
||||
export const LAUNCHER_IMPORT_TYPES: ImportType[] = ['PrismLauncher', 'Curseforge', 'Modrinth', 'ATLauncher', 'GDLauncher', 'FTBLauncher', 'MultiMC', 'TLauncher', 'Technic'] as const;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ export default defineConfig(async ({ mode }) => {
|
|||
emptyOutDir: false,
|
||||
},
|
||||
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 8001,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
use onelauncher::data::PackageType;
|
||||
use onelauncher::package::content;
|
||||
use onelauncher::package::content::Providers;
|
||||
use onelauncher::{Result, State};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let _ = State::get().await?;
|
||||
|
||||
let provider = content::Providers::Modrinth;
|
||||
search(Providers::Modrinth).await?;
|
||||
search(Providers::Curseforge).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(provider: Providers) -> Result<()> {
|
||||
println!("Searching for packages from {}...", provider.name());
|
||||
|
||||
let results = provider
|
||||
.search(
|
||||
Some("chatting".to_string()),
|
||||
Some("Evergreen HUD".to_string()),
|
||||
Some(5),
|
||||
Some(0),
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0-alpha.1",
|
||||
"private": true,
|
||||
"engineStrict": true,
|
||||
"packageManager": "pnpm@9.11.0",
|
||||
"packageManager": "pnpm@9.12.2",
|
||||
"description": "Next-generation open source Minecraft launcher and tools for interacting with Polyfrost",
|
||||
"license": "GPL-3.0-only",
|
||||
"homepage": "https://polyfrost.org",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ tokio = { workspace = true }
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_ini = { workspace = true }
|
||||
serde_repr = { workspace = true }
|
||||
paste = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
|
@ -79,6 +80,9 @@ specta = { workspace = true, optional = true }
|
|||
# features[cli]
|
||||
indicatif = { workspace = true, optional = true }
|
||||
|
||||
[build-dependencies]
|
||||
dotenvy = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = { workspace = true }
|
||||
|
||||
|
|
|
|||
40
packages/core/build.rs
Normal file
40
packages/core/build.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let paths = vec![get_root_workspace().join(".env"), PathBuf::from(".env")];
|
||||
|
||||
for path in paths {
|
||||
if path.exists() {
|
||||
load_from_path(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_root_workspace() -> PathBuf {
|
||||
let output: Vec<u8> = std::process::Command::new(env!("CARGO"))
|
||||
.arg("locate-project")
|
||||
.arg("--workspace")
|
||||
.arg("--message-format=plain")
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout;
|
||||
|
||||
let root_workspace = String::from_utf8(output).unwrap();
|
||||
let mut root_workspace = PathBuf::from(root_workspace.trim());
|
||||
|
||||
if root_workspace.is_file() {
|
||||
root_workspace.pop();
|
||||
}
|
||||
|
||||
root_workspace
|
||||
}
|
||||
|
||||
fn load_from_path(path: PathBuf) {
|
||||
let vars = dotenvy::EnvLoader::with_path(path)
|
||||
.load()
|
||||
.expect("failed to find .env file");
|
||||
|
||||
for (key, value) in vars {
|
||||
println!("cargo:rustc-env={key}={value}");
|
||||
}
|
||||
}
|
||||
|
|
@ -28,12 +28,18 @@ pub async fn find_managed_version(
|
|||
&package.id,
|
||||
game_version.as_ref().map(|v| vec![v.to_owned()]).clone(),
|
||||
loader.map(|l| vec![l]).clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(versions
|
||||
Ok(versions.0
|
||||
.iter()
|
||||
.find(|v| {
|
||||
if package_version.is_some() {
|
||||
return v.id == *package_version.as_ref().unwrap();
|
||||
}
|
||||
|
||||
let check_game_version = game_version
|
||||
.as_ref()
|
||||
.is_some_and(|gv| v.game_versions.iter().any(|gv2| *gv2 == *gv));
|
||||
|
|
@ -42,11 +48,7 @@ pub async fn find_managed_version(
|
|||
.as_ref()
|
||||
.is_some_and(|loader| v.loaders.iter().any(|l| *l == *loader));
|
||||
|
||||
let check_package_version = package_version
|
||||
.as_ref()
|
||||
.map_or(true, |pv| pv == &v.version_id);
|
||||
|
||||
check_game_version && check_loader && check_package_version
|
||||
check_game_version && check_loader
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("no matching version found"))
|
||||
.cloned()?)
|
||||
|
|
|
|||
679
packages/core/src/api/package/content/curseforge.rs
Normal file
679
packages/core/src/api/package/content/curseforge.rs
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
use url::Url;
|
||||
|
||||
use crate::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
|
||||
use crate::store::{ManagedVersionFile, ManagedVersionReleaseType, ProviderSearchResults, SearchResult};
|
||||
use crate::utils::{http, pagination::Pagination};
|
||||
use crate::{Result, State};
|
||||
|
||||
/// Mapping of Curseforge "classes" to their respective category ids
|
||||
#[derive(Debug, Default, Serialize_repr, Deserialize_repr)]
|
||||
#[repr(u32)]
|
||||
pub enum CFPackageType {
|
||||
#[default]
|
||||
Mods = 6,
|
||||
ModPack = 4471,
|
||||
DataPack = 6945,
|
||||
ResourcePack = 12,
|
||||
ShaderPack = 6552,
|
||||
}
|
||||
|
||||
impl From<CFPackageType> for PackageType {
|
||||
fn from(package_type: CFPackageType) -> Self {
|
||||
match package_type {
|
||||
CFPackageType::Mods => PackageType::Mod,
|
||||
CFPackageType::ModPack => PackageType::ModPack,
|
||||
CFPackageType::DataPack => PackageType::DataPack,
|
||||
CFPackageType::ResourcePack => PackageType::ResourcePack,
|
||||
CFPackageType::ShaderPack => PackageType::ShaderPack,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PackageType> for CFPackageType {
|
||||
fn from(package_type: PackageType) -> Self {
|
||||
match package_type {
|
||||
PackageType::Mod => CFPackageType::Mods,
|
||||
PackageType::ModPack => CFPackageType::ModPack,
|
||||
PackageType::DataPack => CFPackageType::DataPack,
|
||||
PackageType::ResourcePack => CFPackageType::ResourcePack,
|
||||
PackageType::ShaderPack => CFPackageType::ShaderPack,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapping of Curseforge supported loaders to their respective id
|
||||
#[derive(Debug, Default, Serialize_repr, Deserialize_repr)]
|
||||
#[repr(u8)]
|
||||
pub enum CurseforgeLoader {
|
||||
#[default]
|
||||
Any = 0,
|
||||
Forge = 1,
|
||||
Fabric = 4,
|
||||
Quilt = 5,
|
||||
NeoForge = 6,
|
||||
}
|
||||
|
||||
impl From<String> for CurseforgeLoader {
|
||||
fn from(loader: String) -> Self {
|
||||
match loader.to_lowercase().as_str() {
|
||||
"forge" => CurseforgeLoader::Forge,
|
||||
"fabric" => CurseforgeLoader::Fabric,
|
||||
"quilt" => CurseforgeLoader::Quilt,
|
||||
"neoforge" => CurseforgeLoader::NeoForge,
|
||||
_ => CurseforgeLoader::Any,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Loader> for CurseforgeLoader {
|
||||
fn from(loader: Loader) -> Self {
|
||||
match loader {
|
||||
Loader::Forge => CurseforgeLoader::Forge,
|
||||
Loader::Fabric => CurseforgeLoader::Fabric,
|
||||
Loader::Quilt => CurseforgeLoader::Quilt,
|
||||
Loader::NeoForge => CurseforgeLoader::NeoForge,
|
||||
_ => CurseforgeLoader::Any,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<Loader> for CurseforgeLoader {
|
||||
fn into(self) -> Loader {
|
||||
match self {
|
||||
CurseforgeLoader::Forge => Loader::Forge,
|
||||
CurseforgeLoader::Fabric => Loader::Fabric,
|
||||
CurseforgeLoader::Quilt => Loader::Quilt,
|
||||
CurseforgeLoader::NeoForge => Loader::NeoForge,
|
||||
_ => Loader::Vanilla,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurseforgePackage {
|
||||
pub id: u32,
|
||||
pub game_id: u32,
|
||||
#[serde(alias = "classId")]
|
||||
pub package_type: CFPackageType,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub links: Links,
|
||||
pub summary: String,
|
||||
// pub status: u32,
|
||||
pub download_count: u64,
|
||||
#[serde(default)]
|
||||
pub is_featured: bool,
|
||||
// pub primary_category_id: u32,
|
||||
pub categories: Vec<Category>,
|
||||
pub authors: Vec<Author>,
|
||||
pub logo: Option<Logo>,
|
||||
// pub screenshots: Vec<Screenshot>,
|
||||
// pub main_file_id: u32,
|
||||
// pub latest_files: Vec<LatestFile>,
|
||||
// pub latest_files_indexes: Vec<LatestFilesIndex>,
|
||||
// pub latest_early_access_files_indexes: Vec<LatestEarlyAccessFilesIndex>,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub date_modified: DateTime<Utc>,
|
||||
pub date_released: DateTime<Utc>,
|
||||
// #[serde(default)]
|
||||
// pub allow_mod_distribution: bool,
|
||||
// pub game_popularity_rank: u32,
|
||||
// pub is_available: bool,
|
||||
pub thumbs_up_count: u32,
|
||||
pub rating: Option<f32>,
|
||||
}
|
||||
|
||||
impl From<CurseforgePackage> for ManagedPackage {
|
||||
fn from(package: CurseforgePackage) -> Self {
|
||||
ManagedPackage {
|
||||
id: package.id.to_string(),
|
||||
title: package.name,
|
||||
provider: super::Providers::Curseforge,
|
||||
package_type: package.package_type.into(),
|
||||
description: package.summary,
|
||||
body: crate::store::PackageBody::Url(format!("/v1/mods/{}/description", package.id)),
|
||||
main: package.slug,
|
||||
versions: vec![],
|
||||
game_versions: vec![],
|
||||
loaders: vec![],
|
||||
icon_url: package.logo.and_then(|l| Some(l.url)),
|
||||
created: package.date_created,
|
||||
updated: package.date_modified,
|
||||
client: crate::store::PackageSide::Unknown,
|
||||
server: crate::store::PackageSide::Unknown,
|
||||
downloads: package.download_count,
|
||||
followers: package.rating.unwrap_or(0.0) as u32,
|
||||
categories: vec![], // TODO
|
||||
optional_categories: None,
|
||||
license: None,
|
||||
author: crate::store::Author::Users(
|
||||
package
|
||||
.authors
|
||||
.into_iter()
|
||||
.map(|a| ManagedUser {
|
||||
id: a.id.to_string(),
|
||||
username: a.name,
|
||||
url: Some(a.url),
|
||||
is_organization_user: false,
|
||||
avatar_url: None,
|
||||
bio: None,
|
||||
role: None,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
is_archived: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<SearchResult> for CurseforgePackage {
|
||||
fn into(self) -> SearchResult {
|
||||
SearchResult {
|
||||
title: self.name,
|
||||
slug: self.slug,
|
||||
project_id: self.id.to_string(),
|
||||
author: self
|
||||
.authors
|
||||
.first()
|
||||
.and_then(|a| Some(a.name.clone()))
|
||||
.unwrap_or(String::from("Unknown")),
|
||||
description: self.summary,
|
||||
client_side: crate::store::PackageSide::Unknown,
|
||||
server_side: crate::store::PackageSide::Unknown,
|
||||
project_type: self.package_type.into(),
|
||||
downloads: self.download_count,
|
||||
icon_url: self.logo.map_or(String::new(), |l| l.url),
|
||||
categories: vec![], // TODO
|
||||
display_categories: vec![],
|
||||
versions: vec![],
|
||||
follows: self.thumbs_up_count,
|
||||
date_created: self.date_created,
|
||||
date_modified: self.date_modified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Links {
|
||||
pub website_url: Option<String>,
|
||||
pub wiki_url: Option<String>,
|
||||
pub issues_url: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Category {
|
||||
pub id: u32,
|
||||
pub game_id: u32,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub url: String,
|
||||
pub icon_url: String,
|
||||
pub date_modified: String,
|
||||
// pub parent_category_id: u32,
|
||||
// pub display_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Author {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Logo {
|
||||
pub id: u32,
|
||||
pub mod_id: u32,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub thumbnail_url: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Screenshot {
|
||||
pub id: u32,
|
||||
pub mod_id: u32,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub thumbnail_url: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize_repr, Deserialize_repr, Clone, Debug)]
|
||||
#[repr(u8)]
|
||||
pub enum ModFileReleaseType {
|
||||
#[default]
|
||||
Release = 1,
|
||||
Beta = 2,
|
||||
Alpha = 3,
|
||||
}
|
||||
|
||||
impl Into<ManagedVersionReleaseType> for ModFileReleaseType {
|
||||
fn into(self) -> ManagedVersionReleaseType {
|
||||
match self {
|
||||
ModFileReleaseType::Release => ManagedVersionReleaseType::Release,
|
||||
ModFileReleaseType::Beta => ManagedVersionReleaseType::Beta,
|
||||
ModFileReleaseType::Alpha => ManagedVersionReleaseType::Alpha,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModFile {
|
||||
pub id: u32,
|
||||
pub game_id: u32,
|
||||
pub mod_id: u32,
|
||||
#[serde(default)]
|
||||
pub is_available: bool,
|
||||
pub display_name: String,
|
||||
pub file_name: String,
|
||||
pub release_type: ModFileReleaseType,
|
||||
// pub file_status: u32,
|
||||
pub hashes: Vec<Hash>,
|
||||
pub file_date: DateTime<Utc>,
|
||||
pub file_length: u64,
|
||||
pub download_count: u32,
|
||||
pub file_size_on_disk: Option<u64>,
|
||||
pub download_url: Option<String>,
|
||||
pub game_versions: Vec<String>,
|
||||
pub sortable_game_versions: Vec<SortableGameVersion>,
|
||||
pub dependencies: Vec<Dependency>,
|
||||
// pub expose_as_alternative: bool,
|
||||
// pub parent_project_file_id: u32,
|
||||
// pub alternate_file_id: u32,
|
||||
// pub is_server_pack: bool,
|
||||
// pub server_pack_file_id: u32,
|
||||
// pub is_early_access_content: bool,
|
||||
// pub early_access_end_date: String,
|
||||
// pub file_fingerprint: u32,
|
||||
// pub modules: Vec<Module>,
|
||||
}
|
||||
|
||||
impl Into<ManagedVersion> for ModFile {
|
||||
fn into(self) -> ManagedVersion {
|
||||
let mut hashes = HashMap::new();
|
||||
|
||||
for hash in self.hashes {
|
||||
hashes.insert(hash.algo.name(), hash.value);
|
||||
}
|
||||
|
||||
let mut game_versions = Vec::new();
|
||||
let mut loader = CurseforgeLoader::Any;
|
||||
|
||||
for version in self.sortable_game_versions {
|
||||
if version.game_version.is_empty() {
|
||||
// This is most likely a loader
|
||||
loader = CurseforgeLoader::from(version.game_version_name);
|
||||
continue;
|
||||
}
|
||||
|
||||
game_versions.push(version.game_version);
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
if let Some(download_url) = self.download_url {
|
||||
files.push(ManagedVersionFile {
|
||||
url: download_url,
|
||||
file_name: self.file_name,
|
||||
primary: true,
|
||||
size: self.file_size_on_disk.unwrap_or(self.file_length),
|
||||
file_type: Some(crate::store::PackageFile::RequiredPack),
|
||||
hashes,
|
||||
});
|
||||
}
|
||||
|
||||
ManagedVersion {
|
||||
package_id: self.mod_id.to_string(),
|
||||
id: self.id.to_string(),
|
||||
name: self.display_name.clone(),
|
||||
author: String::new(),
|
||||
loaders: vec![Into::<Loader>::into(loader)],
|
||||
changelog: String::new(),
|
||||
changelog_url: None,
|
||||
deps: vec![],
|
||||
downloads: self.download_count,
|
||||
featured: false,
|
||||
is_available: self.is_available && files.len() > 0,
|
||||
files,
|
||||
game_versions: game_versions,
|
||||
published: self.file_date,
|
||||
version_display: self.display_name,
|
||||
version_type: self.release_type.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]
|
||||
#[repr(u8)]
|
||||
pub enum HashAlgorithm {
|
||||
Sha1 = 1,
|
||||
MD5 = 2,
|
||||
}
|
||||
|
||||
impl HashAlgorithm {
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
Self::Sha1 => "sha1",
|
||||
Self::MD5 => "md5",
|
||||
}.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hash {
|
||||
pub value: String,
|
||||
pub algo: HashAlgorithm,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SortableGameVersion {
|
||||
pub game_version_name: String,
|
||||
pub game_version_padded: String,
|
||||
pub game_version: String,
|
||||
pub game_version_release_date: String,
|
||||
pub game_version_type_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Dependency {
|
||||
pub mod_id: u32,
|
||||
pub relation_type: u32,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Module {
|
||||
pub name: String,
|
||||
pub fingerprint: u32,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModFilesIndex {
|
||||
pub game_version: String,
|
||||
pub file_id: u32,
|
||||
pub filename: String,
|
||||
pub release_type: u32,
|
||||
pub game_version_type_id: u32,
|
||||
#[serde(default)]
|
||||
pub mod_loader: CurseforgeLoader,
|
||||
}
|
||||
|
||||
// https://docs.curseforge.com/rest-api/?shell#tocS_ModsSearchSortField
|
||||
#[allow(dead_code)]
|
||||
enum SearchSort {
|
||||
Featured = 1,
|
||||
Popularity = 2,
|
||||
LastUpdated = 3,
|
||||
Name = 4,
|
||||
Author = 5,
|
||||
TotalDownloads = 6,
|
||||
Rating = 12,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
struct CFData<T> {
|
||||
data: T,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
struct CFPaginatedData<T> {
|
||||
pagination: Pagination,
|
||||
data: T,
|
||||
}
|
||||
|
||||
pub async fn fetch_advanced<F: FnOnce(&mut Url)>(
|
||||
method: Method,
|
||||
url: &str,
|
||||
url_builder: Option<F>,
|
||||
headers: Option<HashMap<&str, &str>>,
|
||||
json_body: Option<serde_json::Value>,
|
||||
) -> Result<Bytes> {
|
||||
// TODO: Get the API key from settings, fallback to constant, and error if missing
|
||||
let key = crate::constants::CURSEFORGE_API_KEY
|
||||
.ok_or(anyhow::anyhow!("missing curseforge api key"))?;
|
||||
|
||||
let mut headers = headers.unwrap_or(HashMap::<&str, &str>::new());
|
||||
headers.insert("x-api-key", key);
|
||||
|
||||
let url =
|
||||
&mut Url::parse(format!("{}{}", crate::constants::CURSEFORGE_API_URL, url).as_str())?;
|
||||
|
||||
if let Some(url_builder) = url_builder {
|
||||
url_builder(url);
|
||||
}
|
||||
|
||||
http::fetch_advanced(
|
||||
method,
|
||||
url.as_str(),
|
||||
None,
|
||||
json_body,
|
||||
Some(headers),
|
||||
None,
|
||||
&State::get().await?.fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_url_builder<F: FnOnce(&mut Url)>(
|
||||
url: &str,
|
||||
url_builder: Option<F>,
|
||||
) -> Result<Bytes> {
|
||||
fetch_advanced(Method::GET, url, url_builder, None, None).await
|
||||
}
|
||||
|
||||
async fn fetch(url: &str) -> Result<Bytes> {
|
||||
fetch_url_builder(url, None::<fn(&mut Url)>).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn search(
|
||||
query: Option<String>,
|
||||
limit: Option<u8>,
|
||||
offset: Option<u32>,
|
||||
game_versions: Option<Vec<String>>,
|
||||
_categories: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>,
|
||||
package_types: Option<Vec<PackageType>>,
|
||||
) -> Result<ProviderSearchResults> {
|
||||
type CFSearchResults = CFPaginatedData<Vec<CurseforgePackage>>;
|
||||
|
||||
let results: CFSearchResults = serde_json::from_slice(
|
||||
&fetch_url_builder(
|
||||
"/v1/mods/search",
|
||||
Some(Box::new(move |url: &mut Url| {
|
||||
let params = &mut url.query_pairs_mut();
|
||||
|
||||
params.append_pair(
|
||||
"gameId",
|
||||
crate::constants::CURSEFORGE_GAME_ID.to_string().as_str(),
|
||||
);
|
||||
params.append_pair("pageSize", limit.unwrap_or(10).to_string().as_str());
|
||||
params.append_pair("index", offset.unwrap_or(0).to_string().as_str());
|
||||
params.append_pair(
|
||||
"sortField",
|
||||
(SearchSort::Popularity as u8).to_string().as_str(),
|
||||
);
|
||||
params.append_pair("sortOrder", "desc");
|
||||
|
||||
if let Some(query) = query {
|
||||
params.append_pair("searchFilter", query.as_str());
|
||||
}
|
||||
|
||||
if let Some(game_versions) = game_versions {
|
||||
// Need to create a list e.g. ["1.16.5", "1.17.1"]
|
||||
params.append_pair(
|
||||
"gameVersions",
|
||||
format!(
|
||||
"[{}]",
|
||||
game_versions
|
||||
.into_iter()
|
||||
.map(|v| format!("\"{}\"", v))
|
||||
.collect::<Vec<String>>()
|
||||
.join(",")
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(loaders) = loaders {
|
||||
params.append_pair(
|
||||
"modLoaderTypes",
|
||||
format!(
|
||||
"[{}]",
|
||||
loaders
|
||||
.into_iter()
|
||||
.map(|l| (CurseforgeLoader::from(l) as u32).to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(",")
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(package_types) = package_types {
|
||||
if package_types.len() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if package_types.len() > 1 {
|
||||
tracing::warn!(
|
||||
"curseforge provider does not support multiple package types"
|
||||
);
|
||||
}
|
||||
|
||||
let package_type = package_types.first().unwrap();
|
||||
|
||||
params.append_pair(
|
||||
"classId",
|
||||
(CFPackageType::from(*package_type) as u32)
|
||||
.to_string()
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
|
||||
Ok(ProviderSearchResults {
|
||||
total: results.pagination.total_count,
|
||||
results: results
|
||||
.data
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<SearchResult>>(),
|
||||
provider: crate::package::content::Providers::Curseforge,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(id: u32) -> Result<CurseforgePackage> {
|
||||
Ok(
|
||||
serde_json::from_slice::<CFData<_>>(&fetch(format!("/v1/mods/{}", id).as_str()).await?)?
|
||||
.data,
|
||||
)
|
||||
}
|
||||
|
||||
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> {
|
||||
Ok(serde_json::from_slice::<CFData<_>>(&fetch(&url).await?)?.data)
|
||||
}
|
||||
|
||||
pub async fn get_all_versions(
|
||||
project_id: &str,
|
||||
game_versions: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>,
|
||||
page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
) -> Result<(Vec<ModFile>, Pagination)> {
|
||||
let data: CFPaginatedData<Vec<ModFile>> = serde_json::from_slice::<CFPaginatedData<_>>(
|
||||
&fetch_url_builder(
|
||||
format!("/v1/mods/{project_id}/files").as_str(),
|
||||
Some(Box::new(move |url: &mut Url| {
|
||||
if let Some(game_versions) = game_versions {
|
||||
if let Some(version) = game_versions.first() {
|
||||
url.query_pairs_mut().append_pair("gameVersion", version);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(loader) = loaders {
|
||||
if let Some(loader) = loader.first() {
|
||||
url.query_pairs_mut().append_pair(
|
||||
"modLoaderType",
|
||||
(CurseforgeLoader::from(*loader) as u8).to_string().as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let page = page.unwrap_or(0);
|
||||
url.query_pairs_mut().append_pair("index", page.to_string().as_str());
|
||||
|
||||
let page_size = page_size.unwrap_or(10);
|
||||
url.query_pairs_mut().append_pair("pageSize", page_size.to_string().as_str());
|
||||
})),
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
|
||||
Ok((data.data, data.pagination))
|
||||
}
|
||||
|
||||
pub async fn get_versions(
|
||||
versions: Vec<String>,
|
||||
) -> Result<Vec<ManagedVersion>> {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("fileIds".to_string(), versions.iter().map(|v| v.parse::<u32>().unwrap()).collect::<Vec<u32>>().into());
|
||||
|
||||
let json_body = serde_json::Value::Object(map);
|
||||
|
||||
Ok(serde_json::from_slice::<CFData<_>>(
|
||||
&fetch_advanced(
|
||||
Method::POST,
|
||||
format!("/v1/mods/files").as_str(),
|
||||
None::<fn(&mut Url)>,
|
||||
None,
|
||||
Some(json_body)
|
||||
)
|
||||
.await?,
|
||||
)?
|
||||
.data)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -7,9 +7,11 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
|
||||
use crate::package::content::modrinth::FacetBuilder;
|
||||
use crate::store::{Author, ProviderSearchResults};
|
||||
use crate::store::{Author, PackageBody, ProviderSearchResults};
|
||||
use crate::utils::pagination::Pagination;
|
||||
use crate::Result;
|
||||
|
||||
mod curseforge;
|
||||
mod modrinth;
|
||||
|
||||
/// Providers for content packages
|
||||
|
|
@ -17,6 +19,7 @@ mod modrinth;
|
|||
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
|
||||
pub enum Providers {
|
||||
Modrinth,
|
||||
Curseforge,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Providers {
|
||||
|
|
@ -31,6 +34,7 @@ impl Providers {
|
|||
pub const fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Modrinth => "Modrinth",
|
||||
Self::Curseforge => "Curseforge",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +43,7 @@ impl Providers {
|
|||
pub const fn url(&self) -> &str {
|
||||
match self {
|
||||
Self::Modrinth => "https://modrinth.com",
|
||||
Self::Curseforge => "https://curseforge.com",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,94 +60,84 @@ impl Providers {
|
|||
open_source: Option<bool>,
|
||||
) -> Result<ProviderSearchResults> {
|
||||
match self {
|
||||
Self::Modrinth => modrinth::search(
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
Some(|mut builder: FacetBuilder| {
|
||||
if let Some(categories) = categories {
|
||||
for category in categories {
|
||||
builder.and(Facet(
|
||||
"categories".to_string(),
|
||||
FacetOperation::Eq,
|
||||
category,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(game_versions) = game_versions {
|
||||
for version in game_versions {
|
||||
builder.and(Facet("versions".to_string(), FacetOperation::Eq, version));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(package_types) = package_types {
|
||||
if package_types.contains(&PackageType::Mod)
|
||||
|| package_types.contains(&PackageType::ModPack)
|
||||
{
|
||||
if let Some(loaders) = loaders {
|
||||
for loader in loaders {
|
||||
builder.and(Facet(
|
||||
"categories".to_string(),
|
||||
FacetOperation::Eq,
|
||||
loader.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for package_type in package_types {
|
||||
builder.and(Facet(
|
||||
"project_types".to_string(),
|
||||
FacetOperation::Eq,
|
||||
package_type.get_name().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(open_source) = open_source {
|
||||
builder.and(Facet(
|
||||
"open_source".to_string(),
|
||||
FacetOperation::Eq,
|
||||
open_source.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// builder.and(Facet("client_side".to_string(), FacetOperation::Eq, "")) // TODO: Possibly make this client_side = required
|
||||
|
||||
builder.build()
|
||||
}),
|
||||
),
|
||||
Self::Modrinth => {
|
||||
modrinth::search(
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
Some(|mut builder| {
|
||||
build_facets(
|
||||
&mut builder,
|
||||
categories,
|
||||
game_versions,
|
||||
loaders,
|
||||
package_types,
|
||||
open_source,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Self::Curseforge => {
|
||||
curseforge::search(
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
game_versions,
|
||||
categories,
|
||||
loaders,
|
||||
package_types,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<Vec<ManagedPackage>> {
|
||||
Ok(match self {
|
||||
Self::Modrinth => modrinth::list(),
|
||||
}
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get(&self, slug_or_id: &str) -> Result<ManagedPackage> {
|
||||
Ok(match self {
|
||||
Self::Modrinth => modrinth::get(slug_or_id),
|
||||
}
|
||||
.await?
|
||||
.into())
|
||||
Self::Modrinth => modrinth::get(slug_or_id).await?.into(),
|
||||
Self::Curseforge => curseforge::get(
|
||||
slug_or_id
|
||||
.parse::<u32>()
|
||||
.map_err(|err| anyhow::anyhow!(err))?,
|
||||
)
|
||||
.await?
|
||||
.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_multiple(&self, slug_or_ids: &[String]) -> Result<Vec<ManagedPackage>> {
|
||||
Ok(match self {
|
||||
Self::Modrinth => modrinth::get_multiple(slug_or_ids),
|
||||
}
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect())
|
||||
Self::Modrinth => {
|
||||
if slug_or_ids.len() <= 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
modrinth::get_multiple(slug_or_ids)
|
||||
.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(
|
||||
|
|
@ -150,38 +145,124 @@ impl Providers {
|
|||
project_id: &str,
|
||||
game_versions: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>,
|
||||
) -> Result<Vec<ManagedVersion>> {
|
||||
page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
) -> Result<(Vec<ManagedVersion>, Pagination)> {
|
||||
Ok(match self {
|
||||
Self::Modrinth => modrinth::get_all_versions(project_id, game_versions, loaders),
|
||||
}
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect())
|
||||
Self::Modrinth => {
|
||||
let data = modrinth::get_all_versions(project_id, game_versions, loaders, page, page_size).await?;
|
||||
(data.0.into_iter().map(Into::into).collect(), data.1)
|
||||
}
|
||||
|
||||
Self::Curseforge => {
|
||||
let data = curseforge::get_all_versions(project_id, game_versions, loaders, page, page_size).await?;
|
||||
(data.0.into_iter().map(Into::into).collect(), data.1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_versions(&self, versions: Vec<String>) -> Result<Vec<ManagedVersion>> {
|
||||
Ok(match self {
|
||||
Self::Modrinth => modrinth::get_versions(versions),
|
||||
}
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect())
|
||||
Self::Modrinth => modrinth::get_versions(versions)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
Self::Curseforge => curseforge::get_versions(versions)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_version(&self, version_id: &str) -> Result<ManagedVersion> {
|
||||
pub async fn get_version(&self, pkg_id: &str, version_id: &str) -> Result<ManagedVersion> {
|
||||
Ok(match self {
|
||||
Self::Modrinth => modrinth::get_version(version_id),
|
||||
Self::Curseforge => todo!(),
|
||||
}
|
||||
.await?
|
||||
.into())
|
||||
}
|
||||
|
||||
pub async fn get_authors(&self, author: &Author) -> Result<Vec<ManagedUser>> {
|
||||
match self {
|
||||
Self::Modrinth => modrinth::get_authors(author),
|
||||
}
|
||||
.await
|
||||
Ok(match author {
|
||||
Author::Team { .. } => match self {
|
||||
Self::Modrinth => modrinth::get_authors(author).await?,
|
||||
_ => return Err(anyhow::anyhow!("{} does not support teams", self).into()),
|
||||
},
|
||||
Author::Users(users) => users.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_package_body(&self, body: &PackageBody) -> Result<String> {
|
||||
Ok(match body {
|
||||
PackageBody::Url(url) => match self {
|
||||
Self::Curseforge => curseforge::get_package_body(url.to_owned()).await?,
|
||||
_ => return Err(anyhow::anyhow!("{} does not support direct URLs", self).into()),
|
||||
},
|
||||
PackageBody::Markdown(markdown) => markdown.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_facets(
|
||||
builder: &mut FacetBuilder,
|
||||
categories: Option<Vec<String>>,
|
||||
game_versions: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>,
|
||||
package_types: Option<Vec<PackageType>>,
|
||||
open_source: Option<bool>,
|
||||
) -> String {
|
||||
if let Some(categories) = categories {
|
||||
for category in categories {
|
||||
builder.and(Facet(
|
||||
"categories".to_string(),
|
||||
FacetOperation::Eq,
|
||||
category,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(game_versions) = game_versions {
|
||||
for version in game_versions {
|
||||
builder.and(Facet("versions".to_string(), FacetOperation::Eq, version));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(package_types) = package_types {
|
||||
if package_types.contains(&PackageType::Mod)
|
||||
|| package_types.contains(&PackageType::ModPack)
|
||||
{
|
||||
if let Some(loaders) = loaders {
|
||||
for loader in loaders {
|
||||
builder.and(Facet(
|
||||
"categories".to_string(),
|
||||
FacetOperation::Eq,
|
||||
loader.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for package_type in package_types {
|
||||
builder.and(Facet(
|
||||
"project_types".to_string(),
|
||||
FacetOperation::Eq,
|
||||
package_type.get_name().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(open_source) = open_source {
|
||||
builder.and(Facet(
|
||||
"open_source".to_string(),
|
||||
FacetOperation::Eq,
|
||||
open_source.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// builder.and(Facet("client_side".to_string(), FacetOperation::Eq, "")) // TODO: Possibly make this client_side = required
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ use std::fmt::{Display, Formatter};
|
|||
|
||||
use crate::data::{Loader, ManagedPackage, ManagedUser, ManagedVersion, PackageType};
|
||||
use crate::store::{
|
||||
Author, License, ManagedVersionFile, PackageFile, PackageSide, ProviderSearchResults,
|
||||
SearchResult,
|
||||
Author, License, ManagedVersionFile, ManagedVersionReleaseType, PackageFile, PackageSide, ProviderSearchResults, SearchResult
|
||||
};
|
||||
use crate::utils::http::fetch;
|
||||
use crate::utils::pagination::Pagination;
|
||||
use crate::{Result, State};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
@ -68,7 +68,7 @@ impl From<ModrinthPackage> for ManagedPackage {
|
|||
id: value.id,
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
body: value.body,
|
||||
body: crate::store::PackageBody::Markdown(value.body),
|
||||
main: value.slug,
|
||||
versions: value.versions,
|
||||
game_versions: value.game_versions,
|
||||
|
|
@ -78,11 +78,10 @@ impl From<ModrinthPackage> for ManagedPackage {
|
|||
updated: value.updated,
|
||||
client: value.client_side,
|
||||
server: value.server_side,
|
||||
downloads: value.downloads,
|
||||
downloads: value.downloads as u64,
|
||||
followers: value.followers,
|
||||
categories: value.categories,
|
||||
optional_categories: Some(value.additional_categories),
|
||||
uid: None,
|
||||
package_type: PackageType::Mod,
|
||||
license: value.license,
|
||||
author: Author::Team {
|
||||
|
|
@ -146,7 +145,7 @@ pub struct File {
|
|||
pub url: String,
|
||||
pub filename: String,
|
||||
pub primary: bool,
|
||||
pub size: u32,
|
||||
pub size: u64,
|
||||
pub file_type: Option<PackageFile>,
|
||||
}
|
||||
|
||||
|
|
@ -172,15 +171,16 @@ impl From<ModrinthVersion> for ManagedVersion {
|
|||
name: value.name,
|
||||
|
||||
featured: value.featured,
|
||||
version_id: value.version_number,
|
||||
version_display: value.version_number,
|
||||
changelog: value.changelog,
|
||||
changelog_url: value.changelog_url,
|
||||
|
||||
published: value.date_published,
|
||||
downloads: value.downloads,
|
||||
version_type: value.version_type,
|
||||
version_type: ManagedVersionReleaseType::from(value.version_type),
|
||||
|
||||
files: value.files.into_iter().map(Into::into).collect(),
|
||||
is_available: true,
|
||||
deps: vec![], // TODO [`ManagedDependency`]?
|
||||
game_versions: value.game_versions,
|
||||
loaders: value
|
||||
|
|
@ -307,17 +307,6 @@ pub async fn get_multiple(ids: &[String]) -> Result<Vec<ModrinthPackage>> {
|
|||
)?)
|
||||
}
|
||||
|
||||
pub async fn list() -> Result<Vec<ModrinthPackage>> {
|
||||
Ok(serde_json::from_slice(
|
||||
&fetch(
|
||||
format_url!("/projects_random?count=10").as_str(),
|
||||
None,
|
||||
&State::get().await?.fetch_semaphore,
|
||||
)
|
||||
.await?,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub async fn search<F>(
|
||||
query: Option<String>,
|
||||
limit: Option<u8>,
|
||||
|
|
@ -451,7 +440,9 @@ pub async fn get_all_versions(
|
|||
project_id: &str,
|
||||
game_versions: Option<Vec<String>>,
|
||||
loaders: Option<Vec<Loader>>,
|
||||
) -> Result<Vec<ModrinthVersion>> {
|
||||
page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
) -> Result<(Vec<ModrinthVersion>, Pagination)> {
|
||||
let mut url = url::Url::parse(format_url!("/project/{}/version", project_id).as_str())?;
|
||||
if let Some(game_versions) = game_versions {
|
||||
url.query_pairs_mut().append_pair(
|
||||
|
|
@ -479,14 +470,26 @@ pub async fn get_all_versions(
|
|||
);
|
||||
}
|
||||
|
||||
Ok(serde_json::from_slice(
|
||||
let data = serde_json::from_slice::<Vec<ModrinthVersion>>(
|
||||
&fetch(
|
||||
format_url!("/project/{}/version", project_id).as_str(),
|
||||
None,
|
||||
&State::get().await?.fetch_semaphore,
|
||||
)
|
||||
.await?,
|
||||
)?)
|
||||
)?;
|
||||
|
||||
let data_len = data.len() as u32;
|
||||
|
||||
Ok((
|
||||
data,
|
||||
Pagination {
|
||||
index: page.unwrap_or(1),
|
||||
page_size: page_size.unwrap_or(10),
|
||||
result_count: data_len,
|
||||
total_count: data_len,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_versions(versions: Vec<String>) -> Result<Vec<ModrinthVersion>> {
|
||||
|
|
|
|||
|
|
@ -119,10 +119,7 @@ impl ProxyState {
|
|||
Ok(value.app.get_webview_window("main").unwrap())
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::unused_async,
|
||||
reason = "clippy doesn't take into account the features"
|
||||
)]
|
||||
#[allow(clippy::unused_async)]
|
||||
#[cfg(feature = "tauri")]
|
||||
pub async fn get() -> crate::Result<Arc<Self>> {
|
||||
Ok(PROXY_STATE.get().ok_or(ProxyError::NotInitialized)?.clone())
|
||||
|
|
|
|||
|
|
@ -31,12 +31,17 @@ pub const MINECRAFT_REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop
|
|||
pub const MINECRAFT_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL";
|
||||
|
||||
// =========== API ===========
|
||||
// !!! URLS must NOT have a trailing slash. !!!
|
||||
/// The Modrinth API base url.
|
||||
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2";
|
||||
/// The Modrinth V3 API base url. Used for things like fetching information about organizations.
|
||||
pub const MODRINTH_V3_API_URL: &str = "https://api.modrinth.com/v3";
|
||||
/// The Curseforge API base url.
|
||||
pub const CURSEFORGE_API_URL: &str = "https://api.curseforge.com/";
|
||||
pub const CURSEFORGE_API_URL: &str = "https://api.curseforge.com";
|
||||
/// The Curseforge API key. This is a secret and should not be shared.
|
||||
pub const CURSEFORGE_API_KEY: Option<&'static str> = option_env!("CURSEFORGE_API_KEY");
|
||||
/// The Minecraft game ID on Curseforge.
|
||||
pub const CURSEFORGE_GAME_ID: u32 = 432;
|
||||
/// Our metadata API base url.
|
||||
pub const METADATA_API_URL: &str = "https://meta.polyfrost.org";
|
||||
/// <https://mclo.gs>/ API base url.
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ use tokio::fs::DirEntry;
|
|||
|
||||
use super::{ClusterPath, Clusters, Directories, PackagePath};
|
||||
|
||||
// TODO: Curseforge, Modrinth, SkyClient integration
|
||||
|
||||
/// Represents types of packages handled by the launcher.
|
||||
#[cfg_attr(feature = "specta", derive(specta::Type))]
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
|
|
@ -143,7 +141,7 @@ pub struct SearchResult {
|
|||
pub client_side: PackageSide,
|
||||
pub server_side: PackageSide,
|
||||
pub project_type: PackageType,
|
||||
pub downloads: u32,
|
||||
pub downloads: u64,
|
||||
#[serde(default)]
|
||||
pub icon_url: String,
|
||||
pub project_id: String,
|
||||
|
|
@ -154,8 +152,6 @@ pub struct SearchResult {
|
|||
pub follows: u32,
|
||||
pub date_created: DateTime<Utc>,
|
||||
pub date_modified: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub license: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
|
@ -558,7 +554,7 @@ impl PackageMetadata {
|
|||
Self::Managed {
|
||||
package_id: package.id,
|
||||
version_id: version.id,
|
||||
version_formatted: version.version_id,
|
||||
version_formatted: version.version_display,
|
||||
title: package.title,
|
||||
provider: package.provider,
|
||||
package_type: package.package_type,
|
||||
|
|
@ -607,6 +603,13 @@ pub enum Author {
|
|||
Users(Vec<ManagedUser>),
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "specta", derive(specta::Type))]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum PackageBody {
|
||||
Url(String),
|
||||
Markdown(String),
|
||||
}
|
||||
|
||||
/// Universal metadata for any managed package from a Mod distribution platform.
|
||||
#[cfg_attr(feature = "specta", derive(specta::Type))]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
|
@ -614,11 +617,10 @@ pub struct ManagedPackage {
|
|||
// Core Metadata
|
||||
pub provider: Providers,
|
||||
pub id: String,
|
||||
pub uid: Option<String>,
|
||||
pub package_type: PackageType,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
pub body: PackageBody,
|
||||
pub main: String,
|
||||
pub versions: Vec<String>,
|
||||
pub game_versions: Vec<String>,
|
||||
|
|
@ -629,7 +631,7 @@ pub struct ManagedPackage {
|
|||
pub updated: DateTime<Utc>,
|
||||
pub client: PackageSide,
|
||||
pub server: PackageSide,
|
||||
pub downloads: u32,
|
||||
pub downloads: u64,
|
||||
pub followers: u32,
|
||||
pub categories: Vec<String>,
|
||||
pub optional_categories: Option<Vec<String>>,
|
||||
|
|
@ -660,15 +662,16 @@ pub struct ManagedVersion {
|
|||
pub name: String,
|
||||
|
||||
pub featured: bool,
|
||||
pub version_id: String,
|
||||
pub version_display: String,
|
||||
pub changelog: String,
|
||||
pub changelog_url: Option<String>,
|
||||
|
||||
pub published: DateTime<Utc>,
|
||||
pub downloads: u32,
|
||||
pub version_type: String,
|
||||
pub version_type: ManagedVersionReleaseType,
|
||||
|
||||
pub files: Vec<ManagedVersionFile>,
|
||||
pub is_available: bool,
|
||||
pub deps: Vec<ManagedDependency>,
|
||||
pub game_versions: Vec<String>,
|
||||
pub loaders: Vec<Loader>,
|
||||
|
|
@ -681,6 +684,39 @@ impl ManagedVersion {
|
|||
}
|
||||
}
|
||||
|
||||
/// Universal version release type
|
||||
#[cfg_attr(feature = "specta", derive(specta::Type))]
|
||||
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum ManagedVersionReleaseType {
|
||||
#[default]
|
||||
Release,
|
||||
Alpha,
|
||||
Beta,
|
||||
Snapshot,
|
||||
}
|
||||
|
||||
impl From<String> for ManagedVersionReleaseType {
|
||||
fn from(s: String) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"alpha" => Self::Alpha,
|
||||
"beta" => Self::Beta,
|
||||
"snapshot" => Self::Snapshot,
|
||||
_ => Self::Release,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for ManagedVersionReleaseType {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
Self::Release => "Release",
|
||||
Self::Alpha => "Alpha",
|
||||
Self::Beta => "Beta",
|
||||
Self::Snapshot => "Snapshot",
|
||||
}.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Universal interface for managed package files.
|
||||
#[cfg_attr(feature = "specta", derive(specta::Type))]
|
||||
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
|
||||
|
|
@ -689,7 +725,7 @@ pub struct ManagedVersionFile {
|
|||
pub file_name: String,
|
||||
pub primary: bool,
|
||||
|
||||
pub size: u32,
|
||||
pub size: u64,
|
||||
pub file_type: Option<PackageFile>,
|
||||
pub hashes: HashMap<String, String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//!
|
||||
//! Async extensions and wrappers around [`reqwest`] functions.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ pub async fn fetch_advanced(
|
|||
url: &str,
|
||||
sha1: Option<&str>,
|
||||
json_body: Option<serde_json::Value>,
|
||||
header: Option<(&str, &str)>,
|
||||
headers: Option<HashMap<&str, &str>>,
|
||||
ingress: Option<(&IngressId, f64)>,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Bytes> {
|
||||
|
|
@ -93,8 +94,10 @@ pub async fn fetch_advanced(
|
|||
req = req.json(&body);
|
||||
}
|
||||
|
||||
if let Some(header) = header {
|
||||
req = req.header(header.0, header.1);
|
||||
if let Some(headers) = &headers {
|
||||
for (key, value) in headers {
|
||||
req = req.header(*key, *value);
|
||||
}
|
||||
}
|
||||
|
||||
let result = req.send().await;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
pub mod http;
|
||||
pub mod java;
|
||||
pub mod pagination;
|
||||
pub mod watcher;
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
|
|
|
|||
23
packages/core/src/utils/pagination.rs
Normal file
23
packages/core/src/utils/pagination.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg_attr(feature = "specta", derive(specta::Type))]
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(dead_code)]
|
||||
pub struct Pagination {
|
||||
pub index: u32,
|
||||
pub page_size: u16,
|
||||
pub result_count: u32,
|
||||
pub total_count: u32,
|
||||
}
|
||||
|
||||
pub fn get_page_offset(page: Option<u32>, page_size: Option<u16>) -> (u32, u16) {
|
||||
let page = page.unwrap_or(0);
|
||||
let page_size = page_size.unwrap_or(10);
|
||||
let offset = (page - 1) * page_size as u32;
|
||||
(offset, page_size)
|
||||
}
|
||||
|
||||
pub fn get_page_count(total: u32, page_size: u16) -> u32 {
|
||||
(total as f64 / page_size as f64).ceil() as u32
|
||||
}
|
||||
120
packages/scripts/api/fetch_categories.ts
Normal file
120
packages/scripts/api/fetch_categories.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import process from 'node:process';
|
||||
import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
interface MappedCategoryItem {
|
||||
display: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type MappingList = Record<string, MappedCategoryItem[]>;
|
||||
|
||||
const packageTypes = ['mod', 'modpack', 'shader', 'resourcepack'] as const;
|
||||
type PackageType = typeof packageTypes[number];
|
||||
|
||||
async function loadEnv() {
|
||||
const path = await findWorkspaceDir(process.cwd());
|
||||
console.log(`Loading .env from ${path}`);
|
||||
dotenv.config({ path: `${path}/.env` });
|
||||
}
|
||||
|
||||
async function fetchCategoriesCurseforge(packageType: PackageType): Promise<MappingList> {
|
||||
const key = process.env.CURSEFORGE_API_KEY;
|
||||
|
||||
if (key === undefined) {
|
||||
console.error('CURSEFORGE_API_KEY is not defined');
|
||||
return {};
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.curseforge.com/v1/categories?gameId=432', {
|
||||
headers: {
|
||||
'x-api-key': key,
|
||||
},
|
||||
});
|
||||
|
||||
interface CFCategory {
|
||||
id: number;
|
||||
name: 'Server Utility';
|
||||
slug: 'server-utility';
|
||||
url: 'https://www.curseforge.com/minecraft/mc-mods/server-utility';
|
||||
iconUrl: 'https://media.forgecdn.net/avatars/6/48/635351498950580836.png';
|
||||
classId?: number;
|
||||
isClass?: boolean;
|
||||
parentCategoryId?: number;
|
||||
};
|
||||
|
||||
const data = (await response.json() as { data: CFCategory[] }).data;
|
||||
|
||||
// TODO: Add more calssIds
|
||||
const classIds: Record<PackageType, number> = {
|
||||
mod: 6,
|
||||
resourcepack: 12,
|
||||
modpack: 4471,
|
||||
shader: 6552,
|
||||
};
|
||||
|
||||
const mapping: MappingList = {};
|
||||
|
||||
mapping.Curseforge = data
|
||||
.filter((category) => {
|
||||
return category.isClass !== true && category.classId === classIds[packageType];
|
||||
})
|
||||
.map((category) => {
|
||||
return {
|
||||
display: category.name,
|
||||
id: category.id.toString(),
|
||||
};
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
async function fetchCategoriesModrinth(packageType: PackageType): Promise<MappingList> {
|
||||
const response = await fetch('https://api.modrinth.com/v2/tag/category');
|
||||
|
||||
interface ModrinthCategory {
|
||||
icon: string;
|
||||
name: string;
|
||||
project_type: PackageType;
|
||||
header: string;
|
||||
}
|
||||
|
||||
const data = await response.json() as ModrinthCategory[];
|
||||
|
||||
const mapping: MappingList = {};
|
||||
|
||||
mapping.Modrinth = data
|
||||
.filter((category) => {
|
||||
return category.project_type === packageType;
|
||||
})
|
||||
.map((category) => {
|
||||
return {
|
||||
display: category.name.charAt(0).toUpperCase() + category.name.slice(1),
|
||||
id: category.name,
|
||||
};
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const arg = process.argv[2];
|
||||
if (arg === undefined) {
|
||||
console.error(`Please provide an argument for package type.\n Valid: '${packageTypes.join('\', \'')}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageType = arg as PackageType;
|
||||
|
||||
await loadEnv();
|
||||
|
||||
const cfCategories = await fetchCategoriesCurseforge(packageType);
|
||||
const modrinthCategories = await fetchCategoriesModrinth(packageType);
|
||||
|
||||
const categories = {
|
||||
...cfCategories,
|
||||
...modrinthCategories,
|
||||
};
|
||||
|
||||
console.log(categories);
|
||||
})();
|
||||
|
|
@ -22,10 +22,13 @@
|
|||
"setup-nix": "./setup.sh",
|
||||
"autoformat": "./format.sh",
|
||||
"fix-deb": "./fix-deb.sh",
|
||||
"dupe": "./dedupe.sh"
|
||||
"dupe": "./dedupe.sh",
|
||||
"api:categories": "pnpm tsx ./api/fetch_categories.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnpm/find-workspace-dir": "^7.0.1",
|
||||
"consola": "^3.2.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"execa": "^9.4.0",
|
||||
"mustache": "^4.2.0",
|
||||
"pathe": "^1.1.2",
|
||||
|
|
|
|||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
|
|
@ -269,9 +269,15 @@ importers:
|
|||
|
||||
packages/scripts:
|
||||
dependencies:
|
||||
'@pnpm/find-workspace-dir':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
consola:
|
||||
specifier: ^3.2.3
|
||||
version: 3.2.3
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.4.5
|
||||
execa:
|
||||
specifier: ^9.4.0
|
||||
version: 9.4.0
|
||||
|
|
@ -1027,6 +1033,18 @@ packages:
|
|||
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
|
||||
'@pnpm/constants@8.0.0':
|
||||
resolution: {integrity: sha512-yQosGUvYPpAjb1jOFcdbwekRjZRVxN6C0hHzfRCZrMKbxGjt/E0g0RcFlEDNVZ95tm4oMMcr7nEPa7H7LX3emw==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
||||
'@pnpm/error@6.0.1':
|
||||
resolution: {integrity: sha512-7yjO0RgmWYb4OKgcWC33yD4Z2CxE7Tm7vXX1SmS7GDifDT/bgZZhHeS2xq/+W6y9yhwIrRSA+7AlQL1NM2wIvw==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
||||
'@pnpm/find-workspace-dir@7.0.1':
|
||||
resolution: {integrity: sha512-o1LAFM/5MChI6qBolMBOznzatch01UK3wIgoAE/b779qs1FakksB278nMRTwRY58PZSBT+RxZ2RCMjlxPLeVWw==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
||||
'@polka/url@1.0.0-next.25':
|
||||
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
|
||||
|
||||
|
|
@ -4910,6 +4928,17 @@ snapshots:
|
|||
|
||||
'@pkgr/core@0.1.1': {}
|
||||
|
||||
'@pnpm/constants@8.0.0': {}
|
||||
|
||||
'@pnpm/error@6.0.1':
|
||||
dependencies:
|
||||
'@pnpm/constants': 8.0.0
|
||||
|
||||
'@pnpm/find-workspace-dir@7.0.1':
|
||||
dependencies:
|
||||
'@pnpm/error': 6.0.1
|
||||
find-up: 5.0.0
|
||||
|
||||
'@polka/url@1.0.0-next.25': {}
|
||||
|
||||
'@prisma/debug@5.20.0': {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue