From 800b46ac382ac80fa3479f88a5e568c98a50766d Mon Sep 17 00:00:00 2001 From: Juniper Hovey Date: Sat, 21 Mar 2026 15:52:33 -0700 Subject: [PATCH 1/4] Add mod status badges --- Cargo.lock | 7 +++ Cargo.toml | 1 + src/abbreviate.rs | 16 ++++++ src/endpoints/mod.rs | 5 +- src/endpoints/mod_status_badge.rs | 82 +++++++++++++++++++++++++++++++ src/endpoints/mods.rs | 37 ++++++++++++-- src/main.rs | 3 ++ static/mod_downloads.svg | 1 + static/mod_gd_version.svg | 1 + static/mod_geode_version.svg | 1 + static/mod_version.svg | 1 + 11 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 src/abbreviate.rs create mode 100644 src/endpoints/mod_status_badge.rs create mode 100644 static/mod_downloads.svg create mode 100644 static/mod_gd_version.svg create mode 100644 static/mod_geode_version.svg create mode 100644 static/mod_version.svg diff --git a/Cargo.lock b/Cargo.lock index 5a95c59f..3e7df63b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1335,6 +1335,7 @@ dependencies = [ "sqlx", "thiserror 2.0.18", "tokio", + "urlencoding", "utoipa", "utoipa-swagger-ui", "uuid", @@ -3859,6 +3860,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 8207d940..1aba2a68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,3 +43,4 @@ thiserror = "2.0.12" moka = { version = "0.12.13", features = ["future"] } utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "uuid"] } utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] } +urlencoding = "2.1.3" diff --git a/src/abbreviate.rs b/src/abbreviate.rs new file mode 100644 index 00000000..474fe7e9 --- /dev/null +++ b/src/abbreviate.rs @@ -0,0 +1,16 @@ +const ONE_THOUSAND: f64 = 1_000.0; +const ONE_MILLION: f64 = 1_000_000.0; +const ONE_MORBILLION: f64 = 1_000_000_000.0; + +pub fn abbreviate_number(n: i32) -> String { + let n = n as f64; + if n.abs() >= ONE_MORBILLION { + format!("{:.1}B", n / ONE_MORBILLION) + } else if n.abs() >= ONE_MILLION { + format!("{:.1}M", n / ONE_MILLION) + } else if n.abs() >= ONE_THOUSAND { + format!("{:.1}K", n / ONE_THOUSAND) + } else { + format!("{:.0}", n) + } +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index b7a00e10..d8d8ae7f 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -1,10 +1,12 @@ +pub mod mod_status_badge; use crate::{ mod_zip::ModZipError, types::{api::ApiResponse, models::mod_gd_version::PlatformParseError}, }; -use actix_web::{http::StatusCode, HttpResponse}; +use actix_web::{HttpResponse, http::StatusCode}; pub mod auth; +pub mod deprecations; pub mod developers; pub mod health; pub mod loader; @@ -12,7 +14,6 @@ pub mod mod_versions; pub mod mods; pub mod stats; pub mod tags; -pub mod deprecations; #[derive(thiserror::Error, Debug)] pub enum ApiError { diff --git a/src/endpoints/mod_status_badge.rs b/src/endpoints/mod_status_badge.rs new file mode 100644 index 00000000..1ef6e83f --- /dev/null +++ b/src/endpoints/mod_status_badge.rs @@ -0,0 +1,82 @@ +use crate::config::AppData; +use crate::endpoints::ApiError; +use actix_web::{HttpResponse, Responder, get, web}; +use serde::Deserialize; + +use std::fs; +use std::path::Path; +use urlencoding; + +const LABEL_COLOR: &str = "#0c0811"; +const STAT_COLOR: &str = "#5f3d84"; + +#[derive(Deserialize)] +pub struct StatusBadgeQuery { + pub stat: String, +} + +#[utoipa::path( + get, + path = "/v1/mods/{id}/status_badge", + tag = "mods", + params( + ("id" = String, Path, description = "Mod ID"), + ("stat" = String, Query, description = "Stat to display: version, gd_version, geode_version, downloads") + ), + responses( + (status = 302, description = "Redirect to Shields.io badge"), + (status = 400, description = "Invalid stat or missing parameter"), + (status = 404, description = "Mod not found") + ) +)] +#[get("/v1/mods/{id}/status_badge")] +pub async fn status_badge( + _data: web::Data, + id: web::Path, + query: web::Query, +) -> Result { + let (stat, label, svg_path) = match query.stat.as_str() { + "version" => ( + "payload.versions[0].version", + "Version", + "static/mod_version.svg", + ), + "gd_version" => ( + "payload.versions[0].gd.win", + "Geometry Dash", + "static/mod_gd_version.svg", + ), + "geode_version" => ( + "payload.versions[0].geode", + "Geode", + "static/mod_geode_version.svg", + ), + "downloads" => ( + "payload.download_count", + "Downloads", + "static/mod_downloads.svg", + ), + _ => return Err(ApiError::BadRequest("Invalid stat parameter".into())), + }; + let svg = fs::read_to_string(Path::new(svg_path)) + .map_err(|_| ApiError::BadRequest(format!("Could not read SVG file: {}", svg_path)))?; + let api_url = format!( + "{}/v1/mods/{}?abbreviate=true", + "http://api.geode-sdk.org", id + ); + let mod_link = format!("https://geode-sdk.org/mods/{}", id); + let svg_data_url = format!("data:image/svg+xml;utf8,{}", urlencoding::encode(&svg)); + let shields_url = format!( + "https://img.shields.io/badge/dynamic/json?url={}&query={}&label={}&labelColor={}&color={}&link={}&style=plastic&logo={}", + urlencoding::encode(&api_url), + urlencoding::encode(stat), + label, + urlencoding::encode(LABEL_COLOR), + urlencoding::encode(STAT_COLOR), + urlencoding::encode(&mod_link), + urlencoding::encode(&svg_data_url) + ); + Ok(HttpResponse::Found() + .append_header(("Location", shields_url)) + .finish()) +} diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 9bc5c934..536f7248 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -1,3 +1,8 @@ +#[derive(Deserialize, ToSchema)] +pub struct ModGetQueryParams { + pub abbreviate: Option, +} +use crate::abbreviate::abbreviate_number; use crate::config::AppData; use crate::database::repository::developers; use crate::database::repository::incompatibilities; @@ -24,7 +29,7 @@ use actix_web::{HttpResponse, Responder, get, post, put, web}; use serde::Deserialize; use serde::Serialize; use sqlx::Acquire; -use utoipa::{ToSchema, IntoParams}; +use utoipa::{IntoParams, ToSchema}; #[derive(Deserialize, Default, Hash, Eq, PartialEq, ToSchema)] #[serde(rename_all = "snake_case")] @@ -74,7 +79,6 @@ pub struct CreateQueryParams { (status = 403, description = "Forbidden") ) )] - #[get("/v1/mods")] pub async fn index( data: web::Data, @@ -126,6 +130,7 @@ pub async fn index( pub async fn get( data: web::Data, id: web::Path, + query: web::Query, auth: Auth, ) -> Result { let dev = auth.developer().ok(); @@ -171,9 +176,31 @@ pub async fn get( i.modify_metadata(data.app_url(), has_extended_permissions); } + // If abbreviate param is set, abbreviate download_count fields + let mut payload = serde_json::to_value(&the_mod).unwrap(); + if query.abbreviate.unwrap_or(false) { + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "download_count".to_string(), + serde_json::Value::String(abbreviate_number(the_mod.download_count)), + ); + if let Some(versions) = obj.get_mut("versions").and_then(|v| v.as_array_mut()) { + for (i, v) in versions.iter_mut().enumerate() { + if let Some(version_obj) = v.as_object_mut() { + if let Some(dc) = the_mod.versions.get(i) { + version_obj.insert( + "download_count".to_string(), + serde_json::Value::String(abbreviate_number(dc.download_count)), + ); + } + } + } + } + } + } Ok(web::Json(ApiResponse { error: "".into(), - payload: the_mod, + payload, })) } @@ -208,7 +235,9 @@ pub async fn create( let existing: Option = mods::get_one(&json.id, false, &mut pool).await?; if json.id.starts_with("geode.") && !dev.admin { - return Err(ApiError::BadRequest("Only index admins may use mod ids that start with 'geode.'".into())); + return Err(ApiError::BadRequest( + "Only index admins may use mod ids that start with 'geode.'".into(), + )); } if let Some(m) = &existing { diff --git a/src/main.rs b/src/main.rs index 32ac1f7b..15ba4eff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use crate::endpoints::mod_status_badge::status_badge; use crate::openapi::ApiDoc; use crate::types::api; use actix_cors::Cors; @@ -9,6 +10,7 @@ use actix_web::{ use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; +mod abbreviate; mod auth; mod cli; mod config; @@ -64,6 +66,7 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::mods::create) .service(endpoints::mods::update_mod) .service(endpoints::mods::get_logo) + .service(status_badge) .service(endpoints::mod_versions::get_version_index) .service(endpoints::mod_versions::get_one) .service(endpoints::mod_versions::download_version) diff --git a/static/mod_downloads.svg b/static/mod_downloads.svg new file mode 100644 index 00000000..06a71e4d --- /dev/null +++ b/static/mod_downloads.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/mod_gd_version.svg b/static/mod_gd_version.svg new file mode 100644 index 00000000..c77ea886 --- /dev/null +++ b/static/mod_gd_version.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/mod_geode_version.svg b/static/mod_geode_version.svg new file mode 100644 index 00000000..e6b31eef --- /dev/null +++ b/static/mod_geode_version.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/mod_version.svg b/static/mod_version.svg new file mode 100644 index 00000000..8b0289e7 --- /dev/null +++ b/static/mod_version.svg @@ -0,0 +1 @@ + \ No newline at end of file From 8f34f140bbf6a777f2422b3d687a3515e95a96c1 Mon Sep 17 00:00:00 2001 From: Juniper Hovey Date: Sat, 21 Mar 2026 16:12:10 -0700 Subject: [PATCH 2/4] nope --- src/endpoints/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index d8d8ae7f..b7a00e10 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -1,12 +1,10 @@ -pub mod mod_status_badge; use crate::{ mod_zip::ModZipError, types::{api::ApiResponse, models::mod_gd_version::PlatformParseError}, }; -use actix_web::{HttpResponse, http::StatusCode}; +use actix_web::{http::StatusCode, HttpResponse}; pub mod auth; -pub mod deprecations; pub mod developers; pub mod health; pub mod loader; @@ -14,6 +12,7 @@ pub mod mod_versions; pub mod mods; pub mod stats; pub mod tags; +pub mod deprecations; #[derive(thiserror::Error, Debug)] pub enum ApiError { From eb956733d39d669ca838e272e46e30a4cc75a1a5 Mon Sep 17 00:00:00 2001 From: Juniper Hovey Date: Sat, 21 Mar 2026 19:06:01 -0700 Subject: [PATCH 3/4] Address pr + improve serialization --- src/database/repository/mod_versions.rs | 6 +-- src/database/repository/mods.rs | 2 +- src/endpoints/mod.rs | 1 + src/endpoints/mod_status_badge.rs | 36 +++++++++------ src/endpoints/mods.rs | 29 ++---------- src/main.rs | 3 +- src/types/models/download_count.rs | 61 +++++++++++++++++++++++++ src/types/models/mod.rs | 1 + src/types/models/mod_entity.rs | 25 ++++++---- src/types/models/mod_version.rs | 16 +++++-- 10 files changed, 121 insertions(+), 59 deletions(-) create mode 100644 src/types/models/download_count.rs diff --git a/src/database/repository/mod_versions.rs b/src/database/repository/mod_versions.rs index d624dbb6..78bbf4b8 100644 --- a/src/database/repository/mod_versions.rs +++ b/src/database/repository/mod_versions.rs @@ -43,7 +43,7 @@ impl ModVersionRow { geode: self.geode, early_load: self.early_load, requires_patching: self.requires_patching, - download_count: self.download_count, + download_count: self.download_count.into(), api: self.api, mod_id: self.mod_id, status: self.status, @@ -224,7 +224,7 @@ pub async fn create_from_json( download_link: row.download_link, hash: row.hash, geode: geode.to_string(), - download_count: 0, + download_count: 0.into(), early_load: row.early_load, requires_patching: row.requires_patching, api: row.api, @@ -333,7 +333,7 @@ pub async fn update_pending_version( download_link: row.download_link, hash: row.hash, geode: geode.to_string(), - download_count: row.download_count, + download_count: row.download_count.into(), early_load: row.early_load, requires_patching: row.requires_patching, api: row.api, diff --git a/src/database/repository/mods.rs b/src/database/repository/mods.rs index c338801f..54bc4d54 100644 --- a/src/database/repository/mods.rs +++ b/src/database/repository/mods.rs @@ -26,7 +26,7 @@ impl ModRecordGetOne { id: self.id, repository: self.repository, featured: self.featured, - download_count: self.download_count, + download_count: self.download_count.into(), versions: Default::default(), tags: Default::default(), developers: Default::default(), diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index b7a00e10..b519f9ed 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -9,6 +9,7 @@ pub mod developers; pub mod health; pub mod loader; pub mod mod_versions; +pub mod mod_status_badge; pub mod mods; pub mod stats; pub mod tags; diff --git a/src/endpoints/mod_status_badge.rs b/src/endpoints/mod_status_badge.rs index 1ef6e83f..cdaf23df 100644 --- a/src/endpoints/mod_status_badge.rs +++ b/src/endpoints/mod_status_badge.rs @@ -2,6 +2,7 @@ use crate::config::AppData; use crate::endpoints::ApiError; use actix_web::{HttpResponse, Responder, get, web}; use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; use std::fs; use std::path::Path; @@ -10,9 +11,18 @@ use urlencoding; const LABEL_COLOR: &str = "#0c0811"; const STAT_COLOR: &str = "#5f3d84"; -#[derive(Deserialize)] +#[derive(Deserialize, Clone, Copy, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum StatusBadgeStat { + Version, + GdVersion, + GeodeVersion, + Downloads, +} + +#[derive(Deserialize, IntoParams)] pub struct StatusBadgeQuery { - pub stat: String, + pub stat: StatusBadgeStat, } #[utoipa::path( @@ -21,7 +31,7 @@ pub struct StatusBadgeQuery { tag = "mods", params( ("id" = String, Path, description = "Mod ID"), - ("stat" = String, Query, description = "Stat to display: version, gd_version, geode_version, downloads") + StatusBadgeQuery ), responses( (status = 302, description = "Redirect to Shields.io badge"), @@ -31,40 +41,36 @@ pub struct StatusBadgeQuery { )] #[get("/v1/mods/{id}/status_badge")] pub async fn status_badge( - _data: web::Data, + data: web::Data, id: web::Path, query: web::Query, ) -> Result { - let (stat, label, svg_path) = match query.stat.as_str() { - "version" => ( + let (stat, label, svg_path) = match query.stat { + StatusBadgeStat::Version => ( "payload.versions[0].version", "Version", "static/mod_version.svg", ), - "gd_version" => ( + StatusBadgeStat::GdVersion => ( "payload.versions[0].gd.win", "Geometry Dash", "static/mod_gd_version.svg", ), - "geode_version" => ( + StatusBadgeStat::GeodeVersion => ( "payload.versions[0].geode", "Geode", "static/mod_geode_version.svg", ), - "downloads" => ( + StatusBadgeStat::Downloads => ( "payload.download_count", "Downloads", "static/mod_downloads.svg", ), - _ => return Err(ApiError::BadRequest("Invalid stat parameter".into())), }; let svg = fs::read_to_string(Path::new(svg_path)) .map_err(|_| ApiError::BadRequest(format!("Could not read SVG file: {}", svg_path)))?; - let api_url = format!( - "{}/v1/mods/{}?abbreviate=true", - "http://api.geode-sdk.org", id - ); - let mod_link = format!("https://geode-sdk.org/mods/{}", id); + let api_url = format!("{}/v1/mods/{}?abbreviate=true", data.app_url(), id); + let mod_link = format!("{}/mods/{}", data.front_url(), id); let svg_data_url = format!("data:image/svg+xml;utf8,{}", urlencoding::encode(&svg)); let shields_url = format!( "https://img.shields.io/badge/dynamic/json?url={}&query={}&label={}&labelColor={}&color={}&link={}&style=plastic&logo={}", diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 536f7248..f9536dbc 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -2,7 +2,6 @@ pub struct ModGetQueryParams { pub abbreviate: Option, } -use crate::abbreviate::abbreviate_number; use crate::config::AppData; use crate::database::repository::developers; use crate::database::repository::incompatibilities; @@ -27,9 +26,9 @@ use crate::types::models::mod_version_status::ModVersionStatusEnum; use crate::webhook::discord::DiscordWebhook; use actix_web::{HttpResponse, Responder, get, post, put, web}; use serde::Deserialize; -use serde::Serialize; use sqlx::Acquire; use utoipa::{IntoParams, ToSchema}; +use serde::Serialize; #[derive(Deserialize, Default, Hash, Eq, PartialEq, ToSchema)] #[serde(rename_all = "snake_case")] @@ -176,31 +175,11 @@ pub async fn get( i.modify_metadata(data.app_url(), has_extended_permissions); } - // If abbreviate param is set, abbreviate download_count fields - let mut payload = serde_json::to_value(&the_mod).unwrap(); - if query.abbreviate.unwrap_or(false) { - if let Some(obj) = payload.as_object_mut() { - obj.insert( - "download_count".to_string(), - serde_json::Value::String(abbreviate_number(the_mod.download_count)), - ); - if let Some(versions) = obj.get_mut("versions").and_then(|v| v.as_array_mut()) { - for (i, v) in versions.iter_mut().enumerate() { - if let Some(version_obj) = v.as_object_mut() { - if let Some(dc) = the_mod.versions.get(i) { - version_obj.insert( - "download_count".to_string(), - serde_json::Value::String(abbreviate_number(dc.download_count)), - ); - } - } - } - } - } - } + the_mod.set_abbreviated_download_counts(query.abbreviate.unwrap_or(false)); + Ok(web::Json(ApiResponse { error: "".into(), - payload, + payload: the_mod, })) } diff --git a/src/main.rs b/src/main.rs index 15ba4eff..e62ed1bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -use crate::endpoints::mod_status_badge::status_badge; use crate::openapi::ApiDoc; use crate::types::api; use actix_cors::Cors; @@ -66,7 +65,7 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::mods::create) .service(endpoints::mods::update_mod) .service(endpoints::mods::get_logo) - .service(status_badge) + .service(endpoints::mod_status_badge::status_badge) .service(endpoints::mod_versions::get_version_index) .service(endpoints::mod_versions::get_one) .service(endpoints::mod_versions::download_version) diff --git a/src/types/models/download_count.rs b/src/types/models/download_count.rs new file mode 100644 index 00000000..948be824 --- /dev/null +++ b/src/types/models/download_count.rs @@ -0,0 +1,61 @@ +use crate::abbreviate::abbreviate_number; +use serde::{Serialize, Serializer}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DownloadCount { + count: i32, + abbreviate: bool, +} + +impl DownloadCount { + pub const fn new(count: i32) -> Self { + Self { + count, + abbreviate: false, + } + } + + pub fn set_abbreviated(&mut self, abbreviate: bool) { + self.abbreviate = abbreviate; + } +} + +impl From for DownloadCount { + fn from(count: i32) -> Self { + Self::new(count) + } +} + +impl Serialize for DownloadCount { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.abbreviate { + serializer.serialize_str(&abbreviate_number(self.count)) + } else { + serializer.serialize_i32(self.count) + } + } +} + +#[cfg(test)] +mod tests { + use super::DownloadCount; + + #[test] + fn serializes_as_number_by_default() { + let serialized = serde_json::to_string(&DownloadCount::new(1234)).unwrap(); + + assert_eq!(serialized, "1234"); + } + + #[test] + fn serializes_as_abbreviated_string_when_enabled() { + let mut count = DownloadCount::new(1234); + count.set_abbreviated(true); + let serialized = serde_json::to_string(&count).unwrap(); + + assert_eq!(serialized, "\"1.2K\""); + } +} diff --git a/src/types/models/mod.rs b/src/types/models/mod.rs index f9719dc9..e3f46125 100644 --- a/src/types/models/mod.rs +++ b/src/types/models/mod.rs @@ -1,4 +1,5 @@ pub mod dependency; +pub mod download_count; pub mod developer; pub mod github_login_attempt; pub mod incompatibility; diff --git a/src/types/models/mod_entity.rs b/src/types/models/mod_entity.rs index f658be4a..e5039116 100644 --- a/src/types/models/mod_entity.rs +++ b/src/types/models/mod_entity.rs @@ -1,5 +1,6 @@ use super::{ dependency::ResponseDependency, + download_count::DownloadCount, developer::ModDeveloper, incompatibility::{Replacement, ResponseIncompatibility}, mod_gd_version::{DetailedGDVersion, GDVersionEnum, ModGDVersion, VerPlatform}, @@ -22,20 +23,21 @@ use crate::{ }, }; use semver::Version; -use serde::Serialize; -use utoipa::ToSchema; use sqlx::{ PgConnection, types::chrono::{DateTime, Utc}, }; use std::collections::HashMap; +use serde::Serialize; +use utoipa::ToSchema; -#[derive(Serialize, Debug, Clone, sqlx::FromRow, ToSchema)] +#[derive(Serialize, Debug, Clone, ToSchema)] pub struct Mod { pub id: String, pub repository: Option, pub featured: bool, - pub download_count: i32, + #[schema(value_type = i32)] + pub download_count: DownloadCount, pub developers: Vec, pub versions: Vec, pub tags: Vec, @@ -107,6 +109,13 @@ pub struct ModStats { } impl Mod { + pub fn set_abbreviated_download_counts(&mut self, abbreviate: bool) { + self.download_count.set_abbreviated(abbreviate); + for version in &mut self.versions { + version.set_abbreviated_download_count(abbreviate); + } + } + pub async fn get_stats(pool: &mut PgConnection) -> Result { let result = sqlx::query!( " @@ -356,7 +365,7 @@ impl Mod { Mod { id: x.id, repository: x.repository, - download_count: x.download_count, + download_count: x.download_count.into(), featured: x.featured, versions: vec![version], tags, @@ -403,7 +412,7 @@ impl Mod { Mod { id: x.id.clone(), repository: x.repository.clone(), - download_count: x.download_count, + download_count: x.download_count.into(), featured: x.featured, versions: version, tags, @@ -548,7 +557,7 @@ impl Mod { description: x.description.clone(), version: x.version.clone(), download_link: x.download_link.clone(), - download_count: x.mod_version_download_count, + download_count: x.mod_version_download_count.into(), hash: x.hash.clone(), geode: x.geode.clone(), early_load: x.early_load, @@ -592,7 +601,7 @@ impl Mod { id: records[0].id.clone(), repository: records[0].repository.clone(), featured: records[0].featured, - download_count: records[0].mod_download_count, + download_count: records[0].mod_download_count.into(), versions, tags, developers: devs, diff --git a/src/types/models/mod_version.rs b/src/types/models/mod_version.rs index 83ca094d..c284f8db 100644 --- a/src/types/models/mod_version.rs +++ b/src/types/models/mod_version.rs @@ -1,5 +1,6 @@ use super::{ dependency::{Dependency, ModVersionCompare, ResponseDependency}, + download_count::DownloadCount, developer::ModDeveloper, incompatibility::{Incompatibility, ResponseIncompatibility}, mod_gd_version::{DetailedGDVersion, GDVersionEnum, ModGDVersion, VerPlatform}, @@ -13,15 +14,15 @@ use crate::types::{ serde::chrono_dt_secs, }; use semver::Version; -use serde::Serialize; -use utoipa::ToSchema; use sqlx::{ PgConnection, Postgres, QueryBuilder, types::chrono::{DateTime, Utc}, }; use std::collections::HashMap; +use serde::Serialize; +use utoipa::ToSchema; -#[derive(Serialize, Debug, sqlx::FromRow, Clone, ToSchema)] +#[derive(Serialize, Debug, Clone, ToSchema)] pub struct ModVersion { #[serde(skip_serializing)] pub id: i32, @@ -31,7 +32,8 @@ pub struct ModVersion { pub download_link: String, pub hash: String, pub geode: String, - pub download_count: i32, + #[schema(value_type = i32)] + pub download_count: DownloadCount, pub early_load: bool, pub requires_patching: bool, pub api: bool, @@ -101,7 +103,7 @@ impl ModVersionGetOne { geode: self.geode.clone(), early_load: self.early_load, requires_patching: self.requires_patching, - download_count: self.download_count, + download_count: self.download_count.into(), api: self.api, mod_id: self.mod_id.clone(), status: self.status, @@ -128,6 +130,10 @@ impl ModVersionGetOne { } impl ModVersion { + pub fn set_abbreviated_download_count(&mut self, abbreviate: bool) { + self.download_count.set_abbreviated(abbreviate); + } + fn modify_download_link(&mut self, app_url: &str) { self.download_link = create_download_link(app_url, &self.mod_id, &self.version) } From bee2980af5d35aca8456f05f3082d84ced8bd71c Mon Sep 17 00:00:00 2001 From: Juniper Hovey Date: Sat, 21 Mar 2026 19:15:45 -0700 Subject: [PATCH 4/4] Clean diff --- src/abbreviate.rs | 6 +++--- src/endpoints/mods.rs | 9 ++++----- src/types/models/download_count.rs | 21 --------------------- src/types/models/mod_entity.rs | 6 +++--- src/types/models/mod_version.rs | 6 +++--- 5 files changed, 13 insertions(+), 35 deletions(-) diff --git a/src/abbreviate.rs b/src/abbreviate.rs index 474fe7e9..481cece2 100644 --- a/src/abbreviate.rs +++ b/src/abbreviate.rs @@ -1,11 +1,11 @@ const ONE_THOUSAND: f64 = 1_000.0; const ONE_MILLION: f64 = 1_000_000.0; -const ONE_MORBILLION: f64 = 1_000_000_000.0; +const ONE_BILLION: f64 = 1_000_000_000.0; pub fn abbreviate_number(n: i32) -> String { let n = n as f64; - if n.abs() >= ONE_MORBILLION { - format!("{:.1}B", n / ONE_MORBILLION) + if n.abs() >= ONE_BILLION { + format!("{:.1}B", n / ONE_BILLION) } else if n.abs() >= ONE_MILLION { format!("{:.1}M", n / ONE_MILLION) } else if n.abs() >= ONE_THOUSAND { diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index f9536dbc..5e579fd3 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -26,9 +26,9 @@ use crate::types::models::mod_version_status::ModVersionStatusEnum; use crate::webhook::discord::DiscordWebhook; use actix_web::{HttpResponse, Responder, get, post, put, web}; use serde::Deserialize; -use sqlx::Acquire; -use utoipa::{IntoParams, ToSchema}; use serde::Serialize; +use sqlx::Acquire; +use utoipa::{ToSchema, IntoParams}; #[derive(Deserialize, Default, Hash, Eq, PartialEq, ToSchema)] #[serde(rename_all = "snake_case")] @@ -78,6 +78,7 @@ pub struct CreateQueryParams { (status = 403, description = "Forbidden") ) )] + #[get("/v1/mods")] pub async fn index( data: web::Data, @@ -214,9 +215,7 @@ pub async fn create( let existing: Option = mods::get_one(&json.id, false, &mut pool).await?; if json.id.starts_with("geode.") && !dev.admin { - return Err(ApiError::BadRequest( - "Only index admins may use mod ids that start with 'geode.'".into(), - )); + return Err(ApiError::BadRequest("Only index admins may use mod ids that start with 'geode.'".into())); } if let Some(m) = &existing { diff --git a/src/types/models/download_count.rs b/src/types/models/download_count.rs index 948be824..54854654 100644 --- a/src/types/models/download_count.rs +++ b/src/types/models/download_count.rs @@ -38,24 +38,3 @@ impl Serialize for DownloadCount { } } } - -#[cfg(test)] -mod tests { - use super::DownloadCount; - - #[test] - fn serializes_as_number_by_default() { - let serialized = serde_json::to_string(&DownloadCount::new(1234)).unwrap(); - - assert_eq!(serialized, "1234"); - } - - #[test] - fn serializes_as_abbreviated_string_when_enabled() { - let mut count = DownloadCount::new(1234); - count.set_abbreviated(true); - let serialized = serde_json::to_string(&count).unwrap(); - - assert_eq!(serialized, "\"1.2K\""); - } -} diff --git a/src/types/models/mod_entity.rs b/src/types/models/mod_entity.rs index e5039116..a1bc6198 100644 --- a/src/types/models/mod_entity.rs +++ b/src/types/models/mod_entity.rs @@ -23,15 +23,15 @@ use crate::{ }, }; use semver::Version; +use serde::Serialize; +use utoipa::ToSchema; use sqlx::{ PgConnection, types::chrono::{DateTime, Utc}, }; use std::collections::HashMap; -use serde::Serialize; -use utoipa::ToSchema; -#[derive(Serialize, Debug, Clone, ToSchema)] +#[derive(Serialize, Debug, Clone, sqlx::FromRow, ToSchema)] pub struct Mod { pub id: String, pub repository: Option, diff --git a/src/types/models/mod_version.rs b/src/types/models/mod_version.rs index c284f8db..b66c7b77 100644 --- a/src/types/models/mod_version.rs +++ b/src/types/models/mod_version.rs @@ -14,15 +14,15 @@ use crate::types::{ serde::chrono_dt_secs, }; use semver::Version; +use serde::Serialize; +use utoipa::ToSchema; use sqlx::{ PgConnection, Postgres, QueryBuilder, types::chrono::{DateTime, Utc}, }; use std::collections::HashMap; -use serde::Serialize; -use utoipa::ToSchema; -#[derive(Serialize, Debug, Clone, ToSchema)] +#[derive(Serialize, Debug, sqlx::FromRow, Clone, ToSchema)] pub struct ModVersion { #[serde(skip_serializing)] pub id: i32,