From 01d20af628ac86a402c0d736ed6948a5c794a880 Mon Sep 17 00:00:00 2001 From: Erin Grasmick Date: Tue, 3 Sep 2024 13:39:48 +0100 Subject: [PATCH] refactor utils --- src/commands/mint_collection.rs | 189 ++++++++++---------------------- src/commands/mint_nft.rs | 87 ++------------- src/main.rs | 1 + src/utils/ipfs_utils.rs | 46 ++++++++ src/utils/json_utils.rs | 65 +++++++++++ src/utils/mod.rs | 4 + 6 files changed, 181 insertions(+), 211 deletions(-) create mode 100644 src/utils/ipfs_utils.rs create mode 100644 src/utils/json_utils.rs create mode 100644 src/utils/mod.rs diff --git a/src/commands/mint_collection.rs b/src/commands/mint_collection.rs index f559cb4..52ed687 100644 --- a/src/commands/mint_collection.rs +++ b/src/commands/mint_collection.rs @@ -1,18 +1,14 @@ use crate::error::Result; +use crate::utils::{json_utils}; use colored::*; use spinners::{Spinner, Spinners}; use subxt::utils::{AccountId32, MultiAddress}; use crate::commands::assethub; +use crate::commands::mint_nft; use crate::client::get_client; use pallet_nfts::{ItemSettings, CollectionSettings}; use std::marker::PhantomData; -use reqwest::Client; -use serde_json::Value; -use std::fs::File; -use std::path::{Path, PathBuf}; -use std::collections::HashMap; -use std::ffi::OsStr; -use std::error::Error; +use std::path::{Path}; // Function to convert CollectionSettings to the required BitFlags1 type fn to_collection_bitflags( @@ -24,85 +20,6 @@ fn to_collection_bitflags( assethub::runtime_types::pallet_nfts::types::BitFlags1(bits, PhantomData) } -// Function to pin data to IPFS -async fn pin_to_ipfs(data: &[u8]) -> Result { - let pinata_jwt = crate::config::load_pinata_jwt_from_config()?; - let pinata_gateway = "https://api.pinata.cloud"; - - let client = Client::new(); - - if let Some(jwt) = pinata_jwt { - let form = reqwest::multipart::Form::new() - .part("file", reqwest::multipart::Part::bytes(data.to_vec()).file_name("data")); - - let response = client - .post(&format!("{}/pinning/pinFileToIPFS", pinata_gateway)) - .bearer_auth(jwt) // Use JWT for authorization - .multipart(form) - .send() - .await - .map_err(|e| Box::new(e) as Box)?; - - if !response.status().is_success() { - return Err(Box::new(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to pin to IPFS via Pinata: {:?}", response.text().await), - ))); - } - - let pin_response: serde_json::Value = response.json().await.map_err(|e| Box::new(e) as Box)?; - let ipfs_hash = pin_response["IpfsHash"].as_str().ok_or("Failed to parse IPFS hash from Pinata response")?; - - Ok(format!("ipfs://{}", ipfs_hash)) - } else { - let response = client - .post("https://ipfs.io/ipfs") - .body(data.to_vec()) - .send() - .await - .map_err(|e| Box::new(e) as Box)?; - - let ipfs_hash = response.text().await.map_err(|e| Box::new(e) as Box)?; - Ok(format!("ipfs://{}", ipfs_hash)) - } -} - -// Function to load JSON from a file -fn load_json_from_file(path: &Path) -> Result { - let file = File::open(path).map_err(|e| Box::new(e) as Box)?; - serde_json::from_reader(file).map_err(|e| Box::new(e) as Box) -} - -// Function to match JSON files with images based on the "image" field or filename -fn link_json_with_images(json_folder: &Path, image_folder: &Path) -> Result> { - let mut linked_data = HashMap::new(); - - for entry in json_folder.read_dir().map_err(|e| Box::new(e) as Box)? { - let entry = entry.map_err(|e| Box::new(e) as Box)?; - if entry.path().is_file() { - let json_data = load_json_from_file(&entry.path())?; - let image_name = json_data.get("image") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| { - // If "image" field is not present, match by filename - let binding = entry.path(); - let json_filename = binding.file_stem().unwrap_or_else(|| OsStr::new("")).to_string_lossy(); - format!("{}.jpg", json_filename) - }); - - let image_path = image_folder.join(&image_name); - if !image_path.exists() { - return Err(Box::new(std::io::Error::new(std::io::ErrorKind::NotFound, format!("Image file not found: {:?}", image_path)))); - } - - linked_data.insert(image_name, (json_data, image_path)); - } - } - - Ok(linked_data) -} - pub async fn mint_collection(json_folder: Option<&str>, image_folder: Option<&str>) -> Result<()> { let api = get_client().await?; println!("{}", "🚀 Connection with parachain established.".green().bold()); @@ -110,45 +27,6 @@ pub async fn mint_collection(json_folder: Option<&str>, image_folder: Option<&st let account_signer = crate::config::load_account_from_config()?; let admin: MultiAddress = account_signer.public_key().into(); - let mut pinned_data = Vec::new(); - - // Handle optional JSON and image folders - if let (Some(json_folder_str), Some(image_folder_str)) = (json_folder, image_folder) { - let json_folder_path = Path::new(json_folder_str); - let image_folder_path = Path::new(image_folder_str); - - // Link JSON files with their corresponding images - let linked_data = link_json_with_images(json_folder_path, image_folder_path)?; - - for (image_name, (mut json_data, image_path)) in linked_data { - let mut sp = Spinner::new(Spinners::Dots12, format!("🖼️ Processing image: {}", image_name).yellow().bold().to_string()); - - // If the JSON already contains an image link, use it - if let Some(image) = json_data.get("image").and_then(Value::as_str) { - if !image.is_empty() { - sp.stop_and_persist("🔗", format!("Using existing image link in JSON for {}.", image_name).green().bold().to_string()); - } - } - - if json_data.get("image").and_then(Value::as_str).map(|s| s.is_empty()).unwrap_or(true) { - // Pin image to IPFS - let image_bytes = std::fs::read(&image_path).map_err(|e| Box::new(e) as Box)?; - let ipfs_image_link = pin_to_ipfs(&image_bytes).await?; - json_data["image"] = Value::String(ipfs_image_link.clone()); - - sp.stop_and_persist("✅", format!("Image pinned to IPFS and link added to JSON for {}.", image_name).green().bold().to_string()); - } - - // Pin updated JSON to IPFS - let mut sp = Spinner::new(Spinners::Dots12, format!("📦 Pinning JSON metadata for {} to IPFS...", image_name).yellow().bold().to_string()); - let json_bytes = serde_json::to_vec(&json_data).map_err(|e| Box::new(e) as Box)?; - let ipfs_json_link = pin_to_ipfs(&json_bytes).await?; - sp.stop_and_persist("✅", format!("JSON metadata for {} pinned to IPFS.", image_name).green().bold().to_string()); - - pinned_data.push((image_name, ipfs_json_link)); - } - } - let config = assethub::runtime_types::pallet_nfts::types::CollectionConfig { settings: to_collection_bitflags(CollectionSettings::all_enabled()), max_supply: None, @@ -157,26 +35,20 @@ pub async fn mint_collection(json_folder: Option<&str>, image_folder: Option<&st price: None, start_block: None, end_block: None, - default_item_settings: crate::commands::mint_nft::to_item_bitflags( - ItemSettings::all_enabled(), - ), + default_item_settings: mint_nft::to_item_bitflags(ItemSettings::all_enabled()), __ignore: Default::default(), }, __ignore: Default::default(), }; - let payload = assethub::tx().nfts().create(admin, config); + let payload = assethub::tx().nfts().create(admin.clone(), config); let mut sp = Spinner::new(Spinners::Dots12, "⏳ Preparing transaction...".yellow().bold().to_string()); sp.stop_and_persist("🚀", "Sending transaction to the network...".yellow().bold().to_string()); - let extrinsic_result = api - .tx() - .sign_and_submit_then_watch_default(&payload, &account_signer) - .await?; + let extrinsic_result = api.tx().sign_and_submit_then_watch_default(&payload, &account_signer).await?; let mut sp = Spinner::new(Spinners::Dots12, "⏳ Finalizing transaction...".yellow().bold().to_string()); - let extrinsic_result = extrinsic_result.wait_for_finalized_success().await?; sp.stop_and_persist("✅", "Collection creation finalized!".green().bold().to_string()); @@ -193,6 +65,55 @@ pub async fn mint_collection(json_folder: Option<&str>, image_folder: Option<&st "📦 Collection ID".cyan().bold(), collection.to_string().bright_white() ); + + // Step 2: Mint NFTs into the collection if JSON and image paths were provided + if let Some(json_folder_str) = json_folder { + let json_folder_path = Path::new(json_folder_str); + let image_folder_path = image_folder.map(Path::new); + + let entries: Vec<_> = json_folder_path + .read_dir()? + .collect::, _>>()?; + + for (nft_id, entry) in entries.iter().enumerate() { + let json_path = entry.path(); + + let image_path = if let Some(image_folder_path) = image_folder_path { + json_utils::find_image_for_json(&json_path).ok().or_else(|| { + let file_stem = json_path.file_stem()?.to_str()?; + let image_path = image_folder_path.join(format!("{}.jpg", file_stem)); + if image_path.exists() { + Some(image_path) + } else { + let image_path = image_folder_path.join(format!("{}.jpeg", file_stem)); + if image_path.exists() { + Some(image_path) + } else { + let image_path = image_folder_path.join(format!("{}.png", file_stem)); + image_path.exists().then_some(image_path) + } + } + }) + } else { + None + }; + + if image_folder_path.is_some() && image_path.is_none() { + return Err("Error: No image found or provided for the NFT.".into()); + } + + let image_path_str = match image_path { + Some(p) => p.to_str() + .map(|s| s.to_owned()) + .ok_or_else(|| "Failed to convert image path to string".to_string()) + .map(Some), + None => Ok(None), + }?; + + let nft_id = nft_id as u32; + mint_nft::mint_nft(collection, nft_id, Some(json_path.to_str().unwrap()), image_path_str.as_deref()).await?; + } + } } } else { println!("{}", "❌ Collection ID not found in events.".red().bold()); diff --git a/src/commands/mint_nft.rs b/src/commands/mint_nft.rs index 37aa55d..d23b47e 100644 --- a/src/commands/mint_nft.rs +++ b/src/commands/mint_nft.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use crate::utils::{ipfs_utils, json_utils}; use colored::*; use spinners::{Spinner, Spinners}; use subxt::utils::{AccountId32, MultiAddress}; @@ -6,11 +7,9 @@ use crate::commands::assethub; use crate::client::get_client; use pallet_nfts::ItemSettings; use std::marker::PhantomData; -use reqwest::Client; use serde_json::Value; -use std::fs::File; -use std::path::{Path, PathBuf}; -use std::error::Error; +use std::path::Path; +use std::path::PathBuf; // Function to convert ItemSettings to the required BitFlags2 type pub fn to_item_bitflags( @@ -22,67 +21,6 @@ pub fn to_item_bitflags( assethub::runtime_types::pallet_nfts::types::BitFlags2(bits, PhantomData) } -async fn pin_to_ipfs(data: &[u8]) -> Result { - let pinata_jwt = crate::config::load_pinata_jwt_from_config()?; - let pinata_gateway = "https://api.pinata.cloud"; - - let client = Client::new(); - - if let Some(jwt) = pinata_jwt { - let form = reqwest::multipart::Form::new() - .part("file", reqwest::multipart::Part::bytes(data.to_vec()).file_name("data")); - - let response = client - .post(&format!("{}/pinning/pinFileToIPFS", pinata_gateway)) - .bearer_auth(jwt) // Use JWT for authorization - .multipart(form) - .send() - .await - .map_err(|e| Box::new(e) as Box)?; - - if !response.status().is_success() { - return Err(Box::new(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to pin to IPFS via Pinata: {:?}", response.text().await), - ))); - } - - let pin_response: serde_json::Value = response.json().await.map_err(|e| Box::new(e) as Box)?; - let ipfs_hash = pin_response["IpfsHash"].as_str().ok_or("Failed to parse IPFS hash from Pinata response")?; - - Ok(format!("ipfs://{}", ipfs_hash)) - } else { - let response = client - .post("https://ipfs.io/ipfs") - .body(data.to_vec()) - .send() - .await - .map_err(|e| Box::new(e) as Box)?; - - let ipfs_hash = response.text().await.map_err(|e| Box::new(e) as Box)?; - Ok(format!("ipfs://{}", ipfs_hash)) - } -} - -fn load_json_from_file(path: &Path) -> Result { - let file = File::open(path).map_err(|e| Box::new(e) as Box)?; - serde_json::from_reader(file).map_err(|e| Box::new(e) as Box) -} - -fn find_image_for_json(json_path: &Path) -> Result { - let parent_dir = json_path.parent().ok_or("Failed to find parent directory of JSON file")?; - let json_stem = json_path.file_stem().ok_or("Failed to extract file stem from JSON file name")?.to_string_lossy(); - - for extension in &["jpg", "jpeg", "png"] { - let image_path = parent_dir.join(format!("{}.{}", json_stem, extension)); - if image_path.exists() { - return Ok(image_path); - } - } - - Err("No matching image found for the provided JSON file.".into()) -} - pub async fn mint_nft(collection_id: u32, nft_id: u32, json_path: Option<&str>, image_path: Option<&str>) -> Result<()> { let api = get_client().await?; println!("{}", "🚀 Connection with parachain established.".green().bold()); @@ -90,17 +28,15 @@ pub async fn mint_nft(collection_id: u32, nft_id: u32, json_path: Option<&str>, let account_signer = crate::config::load_account_from_config()?; let account: MultiAddress = account_signer.public_key().into(); - // Initialize variables for JSON and image processing let mut json_data: Option = None; let mut image_link = String::new(); if let Some(json_path_str) = json_path { let json_path = Path::new(json_path_str); - json_data = Some(load_json_from_file(json_path)?); + json_data = Some(json_utils::load_json_from_file(json_path)?); let mut sp = Spinner::new(Spinners::Dots12, "🖼️ Processing image...".yellow().bold().to_string()); - // If the JSON already contains an image link, use it if let Some(image) = json_data.as_ref().unwrap().get("image").and_then(Value::as_str) { if !image.is_empty() { image_link = image.to_string(); @@ -112,11 +48,11 @@ pub async fn mint_nft(collection_id: u32, nft_id: u32, json_path: Option<&str>, let image_path_buf = if let Some(image_path_str) = image_path { PathBuf::from(image_path_str) } else { - find_image_for_json(json_path)? + json_utils::find_image_for_json(json_path)? }; - let image_bytes = std::fs::read(&image_path_buf).map_err(|e| Box::new(e) as Box)?; - image_link = pin_to_ipfs(&image_bytes).await?; + let image_bytes = std::fs::read(&image_path_buf)?; + image_link = ipfs_utils::pin_to_ipfs(&image_bytes).await?; json_data.as_mut().unwrap()["image"] = Value::String(image_link.clone()); sp.stop_and_persist("✅", "Image pinned to IPFS and link added to JSON.".green().bold().to_string()); @@ -125,11 +61,10 @@ pub async fn mint_nft(collection_id: u32, nft_id: u32, json_path: Option<&str>, return Err("Error: --json must be provided when using --image.".into()); } - // If JSON was provided, pin it to IPFS let ipfs_json_link = if let Some(json_data) = json_data { let mut sp = Spinner::new(Spinners::Dots12, "📦 Pinning JSON metadata to IPFS...".yellow().bold().to_string()); - let json_bytes = serde_json::to_vec(&json_data).map_err(|e| Box::new(e) as Box)?; - let link = pin_to_ipfs(&json_bytes).await?; + let json_bytes = serde_json::to_vec(&json_data)?; + let link = ipfs_utils::pin_to_ipfs(&json_bytes).await?; sp.stop_and_persist("✅", "JSON metadata pinned to IPFS.".green().bold().to_string()); Some(link) } else { @@ -148,7 +83,7 @@ pub async fn mint_nft(collection_id: u32, nft_id: u32, json_path: Option<&str>, let mut sp = Spinner::new(Spinners::Dots12, "⏳ Minting NFT...".yellow().bold().to_string()); sp.stop(); - let mut sp = Spinner::new(Spinners::Dots12, "🚀 Sending transaction to the network...".yellow().bold().to_string()); + sp = Spinner::new(Spinners::Dots12, "🚀 Sending transaction to the network...".yellow().bold().to_string()); let extrinsic_result = api .tx() @@ -156,8 +91,6 @@ pub async fn mint_nft(collection_id: u32, nft_id: u32, json_path: Option<&str>, .await?; let extrinsic_result = extrinsic_result.wait_for_finalized_success().await?; - sp.stop(); - sp = Spinner::new(Spinners::Dots12, "⏳ Finalizing transaction...".yellow().bold().to_string()); sp.stop_and_persist("✅", "NFT minting finalized!".green().bold().to_string()); let extrinsic_hash = extrinsic_result.extrinsic_hash(); diff --git a/src/main.rs b/src/main.rs index 07e7e74..53d1a01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod commands; mod config; mod error; mod client; +mod utils; use clap::Parser; use cli::Cli; diff --git a/src/utils/ipfs_utils.rs b/src/utils/ipfs_utils.rs new file mode 100644 index 0000000..c33ae64 --- /dev/null +++ b/src/utils/ipfs_utils.rs @@ -0,0 +1,46 @@ +use crate::error::Result; +use reqwest::Client; +use std::error::Error; + +pub async fn pin_to_ipfs(data: &[u8]) -> Result { + let pinata_jwt = crate::config::load_pinata_jwt_from_config()?; + let pinata_gateway = "https://api.pinata.cloud"; + + let client = Client::new(); + + if let Some(jwt) = pinata_jwt { + let form = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(data.to_vec()).file_name("data")); + + let response = client + .post(&format!("{}/pinning/pinFileToIPFS", pinata_gateway)) + .bearer_auth(jwt) // Use JWT for authorization + .multipart(form) + .send() + .await + .map_err(|e| Box::new(e) as Box)?; + + if !response.status().is_success() { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to pin to IPFS via Pinata: {:?}", response.text().await), + ))); + } + + let pin_response: serde_json::Value = response.json().await.map_err(|e| Box::new(e) as Box)?; + let ipfs_hash = pin_response["IpfsHash"].as_str().ok_or("Failed to parse IPFS hash from Pinata response")?; + + Ok(format!("ipfs://{}", ipfs_hash)) + } else { + let response = client + .post("https://ipfs.io/ipfs") + .body(data.to_vec()) + .send() + .await + .map_err(|e| Box::new(e) as Box)?; + + let ipfs_hash = response.text().await.map_err(|e| Box::new(e) as Box)?; + Ok(format!("ipfs://{}", ipfs_hash)) + } +} + diff --git a/src/utils/json_utils.rs b/src/utils/json_utils.rs new file mode 100644 index 0000000..e0c3f0a --- /dev/null +++ b/src/utils/json_utils.rs @@ -0,0 +1,65 @@ +use crate::error::Result; +use serde_json::Value; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::error::Error; + +/// Load a JSON file from the given path and deserialize it into a `serde_json::Value`. +/// Provides detailed error messages if the file cannot be opened or if the JSON is invalid. +pub fn load_json_from_file(path: &Path) -> Result { + // Attempt to open the file, providing a specific error message if it fails. + let file = File::open(path).map_err(|e| { + format!( + "Failed to open JSON file at {}: {}", + path.display(), + e.description() + ) + })?; + + // Attempt to parse the JSON from the file, providing a specific error message if it fails. + serde_json::from_reader(file).map_err(|e| { + format!( + "Failed to parse JSON from file at {}: {}", + path.display(), + e.description() + ) + .into() + }) +} + +/// Find an image file that matches the given JSON file's name. +/// Looks for .jpg, .jpeg, and .png extensions in the same directory as the JSON file. +/// Provides detailed error messages if the parent directory or file stem cannot be determined, +/// or if no matching image is found. +pub fn find_image_for_json(json_path: &Path) -> Result { + // Ensure the JSON file has a parent directory. + let parent_dir = json_path.parent().ok_or_else(|| { + format!( + "Failed to determine parent directory for JSON file at {}", + json_path.display() + ) + })?; + + // Ensure the JSON file has a valid stem (filename without extension). + let json_stem = json_path.file_stem().ok_or_else(|| { + format!( + "Failed to extract file stem from JSON file name at {}", + json_path.display() + ) + })?.to_string_lossy(); + + // Check for image files with the same stem and various extensions. + for extension in &["jpg", "jpeg", "png"] { + let image_path = parent_dir.join(format!("{}.{}", json_stem, extension)); + if image_path.exists() { + return Ok(image_path); + } + } + + // If no image is found, return an error with a detailed message. + Err(format!( + "No matching image found for JSON file at {}. Looked for .jpg, .jpeg, and .png files with the same name.", + json_path.display() + ).into()) +} + diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..61cf74a --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,4 @@ +// src/utils/mod.rs +pub mod ipfs_utils; +pub mod json_utils; +