1use crate::{
2 config::structs::{Mod, ModIdentifier, ModIdentifierRef, ModLoader, Profile},
3 upgrade::check::{self, game_version_check, mod_loader_check},
4 APIs,
5};
6use serde::Deserialize;
7use std::{collections::HashMap, str::FromStr};
8
9#[derive(thiserror::Error, Debug)]
10pub enum Error {
11 #[error(
12 "The developer of this project has denied third party applications from downloading it"
13 )]
14 DistributionDenied,
17 #[error("The project has already been added")]
18 AlreadyAdded,
19 #[error("The project is not compatible")]
20 Incompatible,
21 #[error("The project does not exist")]
22 DoesNotExist,
23 #[error("The project is not a mod")]
24 NotAMod,
25 #[error("GitHub: {0}")]
26 GitHubError(String),
27 #[error("GitHub: {0:#?}")]
28 OctocrabError(#[from] octocrab::Error),
29 #[error("Modrinth: {0}")]
30 ModrinthError(#[from] ferinth::Error),
31 #[error("CurseForge: {0}")]
32 CurseForgeError(#[from] furse::Error),
33}
34type Result<T> = std::result::Result<T, Error>;
35
36#[derive(Deserialize, Debug)]
37struct GraphQlResponse {
38 data: HashMap<String, Option<ResponseData>>,
39 #[serde(default)]
40 errors: Vec<GraphQLError>,
41}
42
43#[derive(Deserialize, Debug)]
44struct GraphQLError {
45 #[serde(rename = "type")]
46 type_: String,
47 path: Vec<String>,
48 message: String,
49}
50
51#[derive(Deserialize, Debug)]
52struct ResponseData {
53 owner: OwnerData,
54 name: String,
55 releases: ReleaseConnection,
56}
57#[derive(Deserialize, Debug)]
58struct OwnerData {
59 login: String,
60}
61#[derive(Deserialize, Debug)]
62struct ReleaseConnection {
63 nodes: Vec<Release>,
64}
65#[derive(Deserialize, Debug)]
66struct Release {
67 #[serde(rename = "releaseAssets")]
68 assets: ReleaseAssetConnection,
69}
70#[derive(Deserialize, Debug)]
71struct ReleaseAssetConnection {
72 nodes: Vec<ReleaseAsset>,
73}
74#[derive(Deserialize, Debug)]
75struct ReleaseAsset {
76 name: String,
77}
78
79pub fn parse_id(id: String) -> ModIdentifier {
80 if let Ok(id) = id.parse() {
81 ModIdentifier::CurseForgeProject(id)
82 } else {
83 let split = id.split('/').collect::<Vec<_>>();
84 if split.len() == 2 {
85 ModIdentifier::GitHubRepository((split[0].to_owned(), split[1].to_owned()))
86 } else {
87 ModIdentifier::ModrinthProject(id)
88 }
89 }
90}
91
92pub async fn add(
96 apis: APIs<'_>,
97 profile: &mut Profile,
98 identifiers: Vec<ModIdentifier>,
99 perform_checks: bool,
100 check_game_version: bool,
101 check_mod_loader: bool,
102) -> Result<(Vec<String>, Vec<(String, Error)>)> {
103 let mut mr_ids = Vec::new();
104 let mut cf_ids = Vec::new();
105 let mut gh_ids = Vec::new();
106 let mut errors = Vec::new();
107
108 for id in identifiers {
109 match id {
110 ModIdentifier::CurseForgeProject(id) => cf_ids.push(id),
111 ModIdentifier::ModrinthProject(id) => mr_ids.push(id),
112 ModIdentifier::GitHubRepository(id) => gh_ids.push(id),
113 }
114 }
115
116 let cf_projects = if !cf_ids.is_empty() {
117 cf_ids.sort_unstable();
118 cf_ids.dedup();
119 apis.cf.get_mods(cf_ids.clone()).await?
120 } else {
121 Vec::new()
122 };
123
124 let mr_projects = if !mr_ids.is_empty() {
125 mr_ids.sort_unstable();
126 mr_ids.dedup();
127 apis.mr
128 .get_multiple_projects(&mr_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>())
129 .await?
130 } else {
131 Vec::new()
132 };
133
134 let gh_repos = {
135 let mut graphql_query = "{".to_string();
137 for (i, (owner, name)) in gh_ids.iter().enumerate() {
138 graphql_query.push_str(&format!(
139 "_{i}: repository(owner: \"{owner}\", name: \"{name}\") {{
140 owner {{
141 login
142 }}
143 name
144 releases(first: 100) {{
145 nodes {{
146 releaseAssets(first: 10) {{
147 nodes {{
148 name
149 }}
150 }}
151 }}
152 }}
153 }}"
154 ));
155 }
156 graphql_query.push('}');
157
158 let response: GraphQlResponse = if !gh_ids.is_empty() {
160 apis.gh
161 .graphql(&HashMap::from([("query", graphql_query)]))
162 .await?
163 } else {
164 GraphQlResponse {
165 data: HashMap::new(),
166 errors: Vec::new(),
167 }
168 };
169
170 errors.extend(response.errors.into_iter().map(|v| {
171 (
172 {
173 let id = &gh_ids[v.path[0]
174 .strip_prefix('_')
175 .and_then(|s| s.parse::<usize>().ok())
176 .expect("Unexpected response data")];
177 format!("{}/{}", id.0, id.1)
178 },
179 if v.type_ == "NOT_FOUND" {
180 Error::DoesNotExist
181 } else {
182 Error::GitHubError(v.message)
183 },
184 )
185 }));
186
187 response
188 .data
189 .into_values()
190 .flatten()
191 .map(|d| {
192 (
193 (d.owner.login, d.name),
194 d.releases
195 .nodes
196 .into_iter()
197 .flat_map(|r| r.assets.nodes.into_iter().map(|e| e.name))
198 .collect::<Vec<_>>(),
199 )
200 })
201 .collect::<Vec<_>>()
202 };
203
204 let mut success_names = Vec::new();
205
206 for project in cf_projects {
207 if let Some(i) = cf_ids.iter().position(|&id| id == project.id) {
208 cf_ids.swap_remove(i);
209 }
210
211 match curseforge(
212 &project,
213 profile,
214 perform_checks,
215 check_game_version,
216 check_mod_loader,
217 ) {
218 Ok(_) => success_names.push(project.name),
219 Err(err) => errors.push((format!("{} ({})", project.name, project.id), err)),
220 }
221 }
222 errors.extend(
223 cf_ids
224 .iter()
225 .map(|id| (id.to_string(), Error::DoesNotExist)),
226 );
227
228 for project in mr_projects {
229 if let Some(i) = mr_ids
230 .iter()
231 .position(|id| id == &project.id || project.slug.eq_ignore_ascii_case(id))
232 {
233 mr_ids.swap_remove(i);
234 }
235
236 match modrinth(
237 &project,
238 profile,
239 perform_checks,
240 check_game_version,
241 check_mod_loader,
242 ) {
243 Ok(_) => success_names.push(project.title),
244 Err(err) => errors.push((format!("{} ({})", project.title, project.id), err)),
245 }
246 }
247 errors.extend(
248 mr_ids
249 .iter()
250 .map(|id| (id.to_string(), Error::DoesNotExist)),
251 );
252
253 for (repo, asset_names) in gh_repos {
254 match github(
255 &repo,
256 profile,
257 Some(&asset_names),
258 check_game_version,
259 check_mod_loader,
260 ) {
261 Ok(_) => success_names.push(format!("{}/{}", repo.0, repo.1)),
262 Err(err) => errors.push((format!("{}/{}", repo.0, repo.1), err)),
263 }
264 }
265
266 Ok((success_names, errors))
267}
268
269pub fn github(
274 id: &(impl AsRef<str> + ToString, impl AsRef<str> + ToString),
275 profile: &mut Profile,
276 perform_checks: Option<&[String]>,
277 check_game_version: bool,
278 check_mod_loader: bool,
279) -> Result<()> {
280 if profile.mods.iter().any(|mod_| {
282 mod_.name.eq_ignore_ascii_case(id.1.as_ref())
283 || ModIdentifierRef::GitHubRepository((id.0.as_ref(), id.1.as_ref()))
284 == mod_.identifier.as_ref()
285 }) {
286 return Err(Error::AlreadyAdded);
287 }
288
289 if let Some(asset_names) = perform_checks {
290 if !asset_names.iter().any(|name| name.ends_with(".jar")) {
292 return Err(Error::NotAMod);
293 }
294
295 check::github(
297 asset_names,
298 profile.get_version(check_game_version),
299 profile.get_loader(check_mod_loader),
300 )
301 .ok_or(Error::Incompatible)?;
302 }
303
304 profile.mods.push(Mod {
306 name: id.1.as_ref().trim().to_string(),
307 identifier: ModIdentifier::GitHubRepository((id.0.to_string(), id.1.to_string())),
308 check_game_version,
309 check_mod_loader,
310 });
311
312 Ok(())
313}
314
315use ferinth::structures::project::{Project, ProjectType};
316
317pub fn modrinth(
320 project: &Project,
321 profile: &mut Profile,
322 perform_checks: bool,
323 check_game_version: bool,
324 check_mod_loader: bool,
325) -> Result<()> {
326 if profile.mods.iter().any(|mod_| {
328 mod_.name.eq_ignore_ascii_case(&project.title)
329 || ModIdentifierRef::ModrinthProject(&project.id) == mod_.identifier.as_ref()
330 }) {
331 Err(Error::AlreadyAdded)
332
333 } else if project.project_type != ProjectType::Mod {
335 Err(Error::NotAMod)
336
337 } else if !perform_checks || (
340 game_version_check(
341 profile.get_version(check_game_version).as_ref(),
342 &project.game_versions,
343 ) && (
344 mod_loader_check(
345 profile.get_loader(check_mod_loader),
346 &project.loaders
347 ) || (
348 profile.mod_loader == ModLoader::Quilt
350 && mod_loader_check(Some(ModLoader::Fabric), &project.loaders)
351 )
352 )
353 )
354 {
355 profile.mods.push(Mod {
357 name: project.title.trim().to_owned(),
358 identifier: ModIdentifier::ModrinthProject(project.id.clone()),
359 check_game_version,
360 check_mod_loader,
361 });
362
363 Ok(())
364 } else {
365 Err(Error::Incompatible)
366 }
367}
368
369pub fn curseforge(
372 project: &furse::structures::mod_structs::Mod,
373 profile: &mut Profile,
374 perform_checks: bool,
375 check_game_version: bool,
376 check_mod_loader: bool,
377) -> Result<()> {
378 if profile.mods.iter().any(|mod_| {
380 mod_.name.eq_ignore_ascii_case(&project.name)
381 || ModIdentifier::CurseForgeProject(project.id) == mod_.identifier
382 }) {
383 Err(Error::AlreadyAdded)
384
385 } else if Some(false) == project.allow_mod_distribution {
387 Err(Error::DistributionDenied)
388
389 } else if !project.links.website_url.as_str().contains("mc-mods") {
391 Err(Error::NotAMod)
392
393 } else if !perform_checks || {
399 let version = profile.get_version(check_game_version);
400 let loader = profile.get_loader(check_mod_loader);
401 project
402 .latest_files_indexes
403 .iter()
404 .map(|f| {
405 (
406 &f.game_version,
407 f.mod_loader
408 .as_ref()
409 .and_then(|l| ModLoader::from_str(&format!("{:?}", l)).ok()),
410 )
411 })
412 .any(|p| {
413 (version.is_none() || version == Some(p.0)) &&
414 (loader.is_none() || loader == p.1)
415 })
416 }
417 {
418 profile.mods.push(Mod {
419 name: project.name.trim().to_string(),
420 identifier: ModIdentifier::CurseForgeProject(project.id),
421 check_game_version,
422 check_mod_loader,
423 });
424
425 Ok(())
426 } else {
427 Err(Error::Incompatible)
428 }
429}