//! Project management + inference use crate::config::{MODRINTH_API_URL, REQWEST_CLIENT}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::json; use sha2::Digest; use std::collections::HashMap; use std::ffi::OsStr; use std::fs::File; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use tokio::io::AsyncReadExt; use zip::ZipArchive; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Project { pub sha512: String, pub disabled: bool, pub metadata: ProjectMetadata, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ModrinthProject { pub id: String, pub slug: Option, pub project_type: String, pub team: String, pub title: String, pub description: String, pub body: String, pub published: DateTime, pub updated: DateTime, pub client_side: String, pub server_side: String, pub downloads: u32, pub followers: u32, pub categories: Vec, pub additional_categories: Vec, pub game_versions: Vec, pub loaders: Vec, pub versions: Vec, pub icon_url: String, } #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ProjectMetadata { Modrinth(Box), Inferred { title: Option, description: Option, authors: Vec, version: Option, icon: Option, }, Unknown, } pub async fn infer_data_from_files( paths: Vec, cache_dir: PathBuf, ) -> crate::Result> { let mut file_path_hashes = HashMap::new(); // TODO: Make this concurrent and use progressive hashing to avoid loading each JAR in memory for path in paths.clone() { let mut file = tokio::fs::File::open(path.clone()).await?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer).await?; let hash = format!("{:x}", sha2::Sha512::digest(&buffer)); file_path_hashes.insert(hash, path.clone()); } // TODO: add disabled mods // TODO: add retrying #[derive(Deserialize)] pub struct ModrinthVersion { pub project_id: String, } let files: HashMap = REQWEST_CLIENT .post(format!("{}version_files", MODRINTH_API_URL)) .json(&json!({ "hashes": file_path_hashes.keys().collect::>(), "algorithm": "sha512", })) .send() .await? .json() .await?; let projects: Vec = REQWEST_CLIENT .get(format!( "{}projects?ids={}", MODRINTH_API_URL, serde_json::to_string( &files .values() .map(|x| x.project_id.clone()) .collect::>() )? )) .send() .await? .json() .await?; let mut return_projects = HashMap::new(); let mut further_analyze_projects: Vec<(String, PathBuf)> = Vec::new(); for (hash, path) in file_path_hashes { if let Some(file) = files.get(&hash) { if let Some(project) = projects.iter().find(|x| file.project_id == x.id) { return_projects.insert( path, Project { sha512: hash, disabled: false, metadata: ProjectMetadata::Modrinth(Box::new( project.clone(), )), }, ); continue; } } further_analyze_projects.push((hash, path)); } for (hash, path) in further_analyze_projects { let file = File::open(path.clone())?; // TODO: get rid of below unwrap let mut zip = ZipArchive::new(file).unwrap(); let read_icon_from_file = |icon_path: Option| -> crate::Result> { if let Some(icon_path) = icon_path { // we have to repoen the zip twice here :( let zip_file = File::open(path.clone())?; if let Ok(mut zip) = ZipArchive::new(zip_file) { if let Ok(mut file) = zip.by_name(&icon_path) { let mut bytes = Vec::new(); if file.read_to_end(&mut bytes).is_ok() { let extension = Path::new(&icon_path) .extension() .and_then(OsStr::to_str); let hash = sha1::Sha1::from(&bytes).hexdigest(); let path = cache_dir.join("icons").join( if let Some(ext) = extension { format!("{hash}.{ext}") } else { hash }, ); if !path.exists() { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let mut file = File::create(path.clone())?; file.write_all(&bytes)?; } return Ok(Some(path)); } }; } } Ok(None) }; if let Ok(mut file) = zip.by_name("META-INF/mods.toml") { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ForgeModInfo { pub mods: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ForgeMod { mod_id: String, version: Option, display_name: Option, description: Option, logo_file: Option, authors: Option, } let mut file_str = String::new(); if file.read_to_string(&mut file_str).is_ok() { if let Ok(pack) = serde_json::from_str::(&file_str) { if let Some(pack) = pack.mods.first() { let icon = read_icon_from_file(pack.logo_file.clone())?; return_projects.insert( path.clone(), Project { sha512: hash, disabled: false, metadata: ProjectMetadata::Inferred { title: Some( pack.display_name .clone() .unwrap_or(pack.mod_id.clone()), ), description: pack.description.clone(), authors: pack .authors .clone() .map(|x| vec![x]) .unwrap_or_default(), version: pack.version.clone(), icon, }, }, ); continue; } } } } if let Ok(mut file) = zip.by_name("mcmod.info") { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ForgeMod { modid: String, name: String, description: Option, version: Option, author_list: Option>, logo_file: Option, } let mut file_str = String::new(); if file.read_to_string(&mut file_str).is_ok() { if let Ok(pack) = serde_json::from_str::(&file_str) { let icon = read_icon_from_file(pack.logo_file)?; return_projects.insert( path.clone(), Project { sha512: hash, disabled: false, metadata: ProjectMetadata::Inferred { title: Some(if pack.name.is_empty() { pack.modid } else { pack.name }), description: pack.description, authors: pack.author_list.unwrap_or_default(), version: pack.version, icon, }, }, ); continue; } } } if let Ok(mut file) = zip.by_name("fabric.mod.json") { #[derive(Deserialize)] #[serde(untagged)] enum FabricAuthor { String(String), Object { name: String }, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct FabricMod { id: String, version: String, name: Option, description: Option, authors: Vec, icon: Option, } let mut file_str = String::new(); if file.read_to_string(&mut file_str).is_ok() { if let Ok(pack) = serde_json::from_str::(&file_str) { let icon = read_icon_from_file(pack.icon)?; return_projects.insert( path.clone(), Project { sha512: hash, disabled: false, metadata: ProjectMetadata::Inferred { title: Some(pack.name.unwrap_or(pack.id)), description: pack.description, authors: pack .authors .into_iter() .map(|x| match x { FabricAuthor::String(name) => name, FabricAuthor::Object { name } => name, }) .collect(), version: Some(pack.version), icon, }, }, ); continue; } } } if let Ok(mut file) = zip.by_name("quilt.mod.json") { #[derive(Deserialize)] struct QuiltMetadata { pub name: Option, pub description: Option, pub contributors: Option>, pub icon: Option, } #[derive(Deserialize)] struct QuiltMod { id: String, version: String, metadata: Option, } let mut file_str = String::new(); if file.read_to_string(&mut file_str).is_ok() { if let Ok(pack) = serde_json::from_str::(&file_str) { let icon = read_icon_from_file( pack.metadata.as_ref().and_then(|x| x.icon.clone()), )?; return_projects.insert( path.clone(), Project { sha512: hash, disabled: false, metadata: ProjectMetadata::Inferred { title: Some( pack.metadata .as_ref() .and_then(|x| x.name.clone()) .unwrap_or(pack.id), ), description: pack .metadata .as_ref() .and_then(|x| x.description.clone()), authors: pack .metadata .map(|x| { x.contributors .unwrap_or_default() .keys() .cloned() .collect() }) .unwrap_or_default(), version: Some(pack.version), icon, }, }, ); continue; } } } if let Ok(mut file) = zip.by_name("pack.mcmeta") { #[derive(Deserialize)] struct Pack { description: Option, } let mut file_str = String::new(); if file.read_to_string(&mut file_str).is_ok() { if let Ok(pack) = serde_json::from_str::(&file_str) { let icon = read_icon_from_file(Some("pack.png".to_string()))?; return_projects.insert( path.clone(), Project { sha512: hash, disabled: false, metadata: ProjectMetadata::Inferred { title: None, description: pack.description, authors: Vec::new(), version: None, icon, }, }, ); continue; } } } return_projects.insert( path, Project { sha512: hash, disabled: false, metadata: ProjectMetadata::Unknown, }, ); } Ok(return_projects) }