use std::collections::{HashMap, HashSet};
use minreq::Request;
use serde::{Deserialize, Serialize};
use crate::{
consts::{MODRINTH_API, MODRINTH_API_V3},
error::Error,
request::{Author, ModrinthId, get, json_or_error},
};
pub type Projects = Vec<Project>;
pub type Teams = Vec<Vec<TeamMember>>;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Project {
pub id: String,
pub slug: String,
pub title: String,
pub description: String,
pub body: String,
pub team: String,
pub project_type: Option<String>,
pub organization: Option<String>,
pub icon_url: Option<String>,
pub issues_url: Option<String>,
pub source_url: Option<String>,
pub wiki_url: Option<String>,
pub license: ProjectLicense,
pub versions: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ProjectLicense {
pub id: String,
pub name: String,
pub url: Option<String>,
}
impl Project {
pub fn url(&self) -> String {
let kind = self.project_type.as_deref().unwrap_or("mod");
format!("https://modrinth.com/{kind}/{}", self.slug)
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TeamMember {
pub team_id: String,
pub user: TeamUser,
pub role: String,
pub accepted: Option<bool>,
pub ordering: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TeamUser {
pub id: String,
pub username: String,
}
impl TeamMember {
fn is_credited(&self) -> bool {
self.accepted.unwrap_or(true)
}
fn into_author(self) -> Author {
Author {
url: format!("https://modrinth.com/user/{}", self.user.username),
name: self.user.username,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Organization {
pub id: String,
pub name: String,
pub slug: String,
}
impl Organization {
fn into_author(self) -> Author {
Author {
url: format!("https://modrinth.com/organization/{}", self.slug),
name: self.name,
}
}
}
pub fn get_modrinth(endpoint: &str) -> Request {
get(format!("{MODRINTH_API}{endpoint}"))
}
pub fn get_modrinth_v3(endpoint: &str) -> Request {
get(format!("{MODRINTH_API_V3}{endpoint}"))
}
pub fn get_modrinth_projects(projects: Vec<ModrinthId>) -> Result<Projects, Error> {
let json = serde_json::to_string(&projects)?;
let response = get_modrinth("/projects").with_param("ids", json).send()?;
json_or_error("Modrinth", response)
}
pub fn get_modrinth_teams(teams: Vec<String>) -> Result<Teams, Error> {
let json = serde_json::to_string(&teams)?;
let response = get_modrinth("/teams").with_param("ids", json).send()?;
json_or_error("Modrinth", response)
}
pub fn get_modrinth_mods(ids: Vec<ModrinthId>) -> Result<Vec<crate::request::Mod>, Error> {
let projects = get_modrinth_projects(ids)?;
let by_team = authors_by_team(&projects);
let by_organization = authors_by_organization(&projects, &by_team);
Ok(projects
.into_iter()
.map(|project| {
let authors = team_authors(&project, &by_team)
.cloned()
.or_else(|| {
let id = project.organization.as_ref()?;
Some(vec![by_organization.get(id)?.clone()])
})
.unwrap_or_default();
let mut m = crate::request::Mod::from(project);
m.authors = authors;
m
})
.collect())
}
fn team_authors<'a>(
project: &Project,
by_team: &'a HashMap<String, Vec<Author>>,
) -> Option<&'a Vec<Author>> {
by_team
.get(&project.team)
.filter(|authors| !authors.is_empty())
}
fn authors_by_organization(
projects: &[Project],
by_team: &HashMap<String, Vec<Author>>,
) -> HashMap<String, Author> {
let ids: Vec<String> = projects
.iter()
.filter(|project| team_authors(project, by_team).is_none())
.filter_map(|project| project.organization.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
if ids.is_empty() {
return HashMap::new();
}
match get_modrinth_organizations(ids) {
Ok(organizations) => organizations
.into_iter()
.map(|organization| (organization.id.clone(), organization.into_author()))
.collect(),
Err(err) => {
log::warn!(
"could not fetch Modrinth organizations ({err}); \
organization-owned mods will have no author"
);
HashMap::new()
}
}
}
fn authors_by_team(projects: &[Project]) -> HashMap<String, Vec<Author>> {
let ids: Vec<String> = projects
.iter()
.map(|project| project.team.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
if ids.is_empty() {
return HashMap::new();
}
let teams = match get_modrinth_teams(ids) {
Ok(teams) => teams,
Err(err) => {
log::warn!("could not fetch Modrinth teams ({err}); author names will be missing");
return HashMap::new();
}
};
let mut by_team: HashMap<String, Vec<TeamMember>> = HashMap::new();
for member in teams.into_iter().flatten() {
if member.is_credited() {
by_team
.entry(member.team_id.clone())
.or_default()
.push(member);
}
}
by_team
.into_iter()
.map(|(team, mut members)| {
sort_members(&mut members);
(
team,
members.into_iter().map(TeamMember::into_author).collect(),
)
})
.collect()
}
pub fn get_modrinth_organizations(ids: Vec<String>) -> Result<Vec<Organization>, Error> {
let json = serde_json::to_string(&ids)?;
let response = get_modrinth_v3("/organizations")
.with_param("ids", json)
.send()?;
json_or_error("Modrinth", response)
}
fn sort_members(members: &mut [TeamMember]) {
members.sort_by(|a, b| {
a.ordering
.unwrap_or_default()
.cmp(&b.ordering.unwrap_or_default())
.then_with(|| (a.role != "Owner").cmp(&(b.role != "Owner")))
.then_with(|| {
a.user
.username
.to_lowercase()
.cmp(&b.user.username.to_lowercase())
})
});
}