diff --git a/addonscript/src/manifest/mod.rs b/addonscript/src/manifest/mod.rs index e304851..ef2783c 100644 --- a/addonscript/src/manifest/mod.rs +++ b/addonscript/src/manifest/mod.rs @@ -19,7 +19,14 @@ pub struct Manifest { #[derive(Deserialize, Serialize, Debug)] pub struct Meta { pub name: String, + #[serde(default)] pub contributors: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website_url: Option, } #[derive(Deserialize, Serialize, Debug)] @@ -60,6 +67,8 @@ pub struct Relation { pub file: Option, #[serde(skip_serializing_if = "Option::is_none")] pub versions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, #[serde(rename = "type")] pub relation_type: RelationType, pub options: HashSet, diff --git a/mpt/Cargo.toml b/mpt/Cargo.toml index 8bd0c64..65ade96 100644 --- a/mpt/Cargo.toml +++ b/mpt/Cargo.toml @@ -22,6 +22,7 @@ reqwest = { version = "0.11.4", features = ["stream"] } serde = { version = "1.0.129", features = ["derive"] } serde_json = "1.0.67" structopt = "0.3.22" +tera = "1.12.1" thiserror = "1.0.28" tokio = { version = "1.10.1", features = ["rt-multi-thread", "macros", "fs"] } toml = "0.5.8" diff --git a/mpt/assets/modlist.html.tera b/mpt/assets/modlist.html.tera new file mode 100644 index 0000000..2b047a4 --- /dev/null +++ b/mpt/assets/modlist.html.tera @@ -0,0 +1,121 @@ + + + + + + + Mod List + + + + + + + + + + + + + + + + {% for m in metas %} + + + + + + + {% endfor %} + +
NameContributorsDescription
+ {% if m.icon_url %} + {% if m.website_url %} + + {% else %} + + {% endif %} + {% endif %} + + + {{ m.name }} + + +
    + {% for cont in m.contributors %} +
  • {{ cont.name }}
  • + {% endfor %} +
+
+ {% if m.description %} +

{{ m.description }}

+ {% endif %} +
+ + + diff --git a/mpt/src/commands/createmodlist.rs b/mpt/src/commands/createmodlist.rs new file mode 100644 index 0000000..19b4253 --- /dev/null +++ b/mpt/src/commands/createmodlist.rs @@ -0,0 +1,182 @@ +use crate::{config::Config, util, util::CliStyle}; +use addonscript::manifest::{ + Contributor, + File, + Manifest, + Meta, + Relation, + Repository, + RepositoryType, +}; +use anyhow::Context; +use crossterm::style::Stylize; +use futures::{stream, StreamExt}; +use indicatif::ProgressBar; +use reqwest::Client; +use serde::Serialize; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use tera::Tera; +use twitch::api::AddonInfoResponse; + +const TEMPLATE: &str = include_str!("../../assets/modlist.html.tera"); + +pub async fn run( + (config, mut manifest): (Config, Manifest), + outfile: PathBuf, +) -> anyhow::Result<()> { + let version = manifest + .versions + .pop() + .context("Manifest has no versions!")?; + let repos = util::repo_map(manifest.repositories); + + let mut tera = Tera::default(); + tera.add_raw_template("modlist", TEMPLATE)?; + + let http = Arc::new(Client::new()); + + let len = version.relations.len(); + + let mut futures = stream::iter(version.relations.into_iter()) + .map(|rel| get_meta(Arc::clone(&http), rel, &repos)) + .buffer_unordered(config.downloads.max_threads); + + let mut metas = vec![]; + let pb = ProgressBar::new(len as u64) + .with_style(util::progress_style()) + .with_prefix("Resolving metadata"); + while let Some(meta) = futures.next().await { + pb.inc(1); + let meta = meta?; + pb.println(format!( + "{} {}", + "Got meta for".green(), + AsRef::::as_ref(&meta.name).cyan().bold() + )); + metas.push(meta); + } + pb.finish(); + + metas.sort_by_key(|m| m.name.to_ascii_lowercase()); + + println!("{}", "Rendering modlist.".info()); + + let rendered = tera.render( + "modlist", + &tera::Context::from_serialize(&ModListContent { + metas, + pack_meta: manifest.meta, + })?, + )?; + + println!("{}", "Writing file.".info()); + if let Some(parent) = outfile.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(outfile, rendered).await?; + + Ok(()) +} + +async fn get_meta( + http: Arc, + rel: Relation, + repos: &HashMap, +) -> anyhow::Result { + if let Some(meta) = rel.meta { + return Ok(meta); + } + + if rel.file.is_none() { + return Ok(Meta { + name: rel.id.clone(), + contributors: vec![], + description: rel.versions.map(|v| format!("version {}", v)), + icon_url: None, + website_url: None, + }); + } + + let file = rel.file.unwrap(); + + match file { + File::Link { link, id, .. } => { + Ok(Meta { + name: if let Some(id) = id { + id + } else { + util::link_file_name(&link)? + }, + contributors: vec![], + // this is fine, since if a link relation is used you should probably add a local + // meta object anyways, in which case this code won't be reached. + description: Some(format!("{:?}", &link)), + icon_url: None, + website_url: None, + }) + }, + File::Maven { + repository, + artifact, + .. + } => { + let repo = repos + .get(&repository) + .context("File references unknown repository!")?; + + match repo.repo_type { + RepositoryType::Maven => Ok(Meta { + name: artifact, + contributors: vec![], + description: None, + icon_url: None, + website_url: None, + }), + RepositoryType::Curseforge => { + let (p_id, _) = util::parse_curseforge_artifact(&artifact)?; + let resp = http + .get(format!( + "https://addons-ecs.forgesvc.net/api/v2/addon/{}", + p_id + )) + .send() + .await? + .bytes() + .await?; + + let resp = serde_json::from_slice::(&resp) + .context("Failed to deserialize addon info!")?; + + let icon_url = resp.attachments.into_iter().find_map(|a| { + if a.is_default { + Some(a.url.into()) + } else { + None + } + }); + + Ok(Meta { + name: resp.name, + contributors: resp + .authors + .into_iter() + .map(|a| Contributor { + name: a.name, + roles: vec![], + }) + .collect(), + description: Some(resp.summary), + icon_url, + website_url: Some(resp.website_url), + }) + }, + } + }, + } +} + +#[derive(Serialize)] +struct ModListContent { + metas: Vec, + pack_meta: Meta, +} diff --git a/mpt/src/commands/init.rs b/mpt/src/commands/init.rs index 1798edb..235334c 100644 --- a/mpt/src/commands/init.rs +++ b/mpt/src/commands/init.rs @@ -122,6 +122,7 @@ pub async fn run( mcver = mcversion, forgever = ver, )), + meta: None, }) } else { execute!( @@ -166,6 +167,9 @@ pub async fn run( roles: vec!["Owner".to_owned()], name: author_name, }], + description: None, + icon_url: None, + website_url: None, }, })?; diff --git a/mpt/src/commands/mod.rs b/mpt/src/commands/mod.rs index 3d5d913..2983d09 100644 --- a/mpt/src/commands/mod.rs +++ b/mpt/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod buildtwitch; pub mod clean; +pub mod createmodlist; pub mod downloadmods; pub mod init; diff --git a/mpt/src/main.rs b/mpt/src/main.rs index ebe662f..d1bb821 100644 --- a/mpt/src/main.rs +++ b/mpt/src/main.rs @@ -45,6 +45,18 @@ enum Command { #[structopt(about = "Deletes artifacts and temporary files")] Clean, + + #[structopt( + name = "createmodlist", + about = "Creates a HTML list of the pack's mods." + )] + CreateModList { + #[structopt( + default_value = "build/modlist.html", + help = "File to write the mod list to" + )] + outfile: PathBuf, + }, } #[tokio::main] @@ -67,6 +79,10 @@ async fn main() -> anyhow::Result<()> { }, Command::Clean => commands::clean::run(util::parse_config().await?).await?, + + Command::CreateModList { outfile } => { + commands::createmodlist::run(util::parse_config_and_manifest().await?, outfile).await? + }, } Ok(()) diff --git a/mpt/src/util.rs b/mpt/src/util.rs index b635640..55e6dcd 100644 --- a/mpt/src/util.rs +++ b/mpt/src/util.rs @@ -1,4 +1,4 @@ -use addonscript::manifest::{Manifest, Repository}; +use addonscript::manifest::{link::Link, Manifest, Repository}; use anyhow::Context; use crossterm::style::{Attribute, Attributes, Color, ContentStyle, Stylize}; use indicatif::ProgressStyle; @@ -156,6 +156,25 @@ pub fn url_file_name(url: &Url) -> Result { Ok(String::from_utf8(bytes)?) } +#[derive(Debug, Error)] +pub enum LinkFileNameError { + #[error("Empty path has no file name!")] + EmptyPath, + #[error("Failed to get file name of URL link: {0}")] + Url(#[from] UrlFileNameError), +} + +pub fn link_file_name(link: &Link) -> Result { + match link { + Link::File(path) => Ok(path + .file_name() + .ok_or(LinkFileNameError::EmptyPath)? + .to_string_lossy() + .to_string()), + Link::Http(url) => Ok(url_file_name(url)?), + } +} + /// Copies a directory inclding all files pub async fn copy_dir(from: PathBuf, to: PathBuf) -> io::Result<()> { for file in WalkDir::new(&from) { diff --git a/twitch/Cargo.toml b/twitch/Cargo.toml index b56108f..a9247ce 100644 --- a/twitch/Cargo.toml +++ b/twitch/Cargo.toml @@ -9,3 +9,4 @@ edition = "2018" addonscript = { path = "../addonscript" } serde = { version = "1.0.130", features = ["derive"] } thiserror = "1.0.28" +url = { version = "2.2.2", features = ["serde"] } diff --git a/twitch/src/api.rs b/twitch/src/api.rs new file mode 100644 index 0000000..7c07f98 --- /dev/null +++ b/twitch/src/api.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +/// API Response for https://addons-ecs.forgesvc.net/api/v2/addon/{addonID}. +/// +/// Only includes fields which are used currently. +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AddonInfoResponse { + pub name: String, + pub authors: Vec, + pub attachments: Vec, + pub summary: String, + pub website_url: Url, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Author { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Attachment { + pub is_default: bool, + pub url: Url, +} diff --git a/twitch/src/lib.rs b/twitch/src/lib.rs index 640fc64..23efd9a 100644 --- a/twitch/src/lib.rs +++ b/twitch/src/lib.rs @@ -1 +1,2 @@ +pub mod api; pub mod manifest;