Skip to content

Commit 66d7b00

Browse files
Add mod status badges + abbreviate query param on mod info (#63)
1 parent 31571d2 commit 66d7b00

17 files changed

Lines changed: 193 additions & 11 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,5 @@ thiserror = "2.0.12"
4343
moka = { version = "0.12.13", features = ["future"] }
4444
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "uuid"] }
4545
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] }
46+
urlencoding = "2.1.3"
4647
validator = { version = "0.20.0", features = ["derive"] }

src/abbreviate.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const ONE_THOUSAND: f64 = 1_000.0;
2+
const ONE_MILLION: f64 = 1_000_000.0;
3+
const ONE_BILLION: f64 = 1_000_000_000.0;
4+
5+
pub fn abbreviate_number(n: i32) -> String {
6+
let n = n as f64;
7+
if n.abs() >= ONE_BILLION {
8+
format!("{:.1}B", n / ONE_BILLION)
9+
} else if n.abs() >= ONE_MILLION {
10+
format!("{:.1}M", n / ONE_MILLION)
11+
} else if n.abs() >= ONE_THOUSAND {
12+
format!("{:.1}K", n / ONE_THOUSAND)
13+
} else {
14+
format!("{:.0}", n)
15+
}
16+
}

src/database/repository/mod_versions.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ impl ModVersionRow {
4343
geode: self.geode,
4444
early_load: self.early_load,
4545
requires_patching: self.requires_patching,
46-
download_count: self.download_count,
46+
download_count: self.download_count.into(),
4747
api: self.api,
4848
mod_id: self.mod_id,
4949
status: self.status,
@@ -224,7 +224,7 @@ pub async fn create_from_json(
224224
download_link: row.download_link,
225225
hash: row.hash,
226226
geode: geode.to_string(),
227-
download_count: 0,
227+
download_count: 0.into(),
228228
early_load: row.early_load,
229229
requires_patching: row.requires_patching,
230230
api: row.api,
@@ -333,7 +333,7 @@ pub async fn update_pending_version(
333333
download_link: row.download_link,
334334
hash: row.hash,
335335
geode: geode.to_string(),
336-
download_count: row.download_count,
336+
download_count: row.download_count.into(),
337337
early_load: row.early_load,
338338
requires_patching: row.requires_patching,
339339
api: row.api,

src/database/repository/mods.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ impl ModRecordGetOne {
2626
id: self.id,
2727
repository: self.repository,
2828
featured: self.featured,
29-
download_count: self.download_count,
29+
download_count: self.download_count.into(),
3030
versions: Default::default(),
3131
tags: Default::default(),
3232
developers: Default::default(),

src/endpoints/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod developers;
99
pub mod health;
1010
pub mod loader;
1111
pub mod mod_versions;
12+
pub mod mod_status_badge;
1213
pub mod mods;
1314
pub mod stats;
1415
pub mod tags;

src/endpoints/mod_status_badge.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
use crate::config::AppData;
2+
use crate::endpoints::ApiError;
3+
use actix_web::{HttpResponse, Responder, get, web};
4+
use serde::Deserialize;
5+
use utoipa::{IntoParams, ToSchema};
6+
7+
use std::fs;
8+
use std::path::Path;
9+
use urlencoding;
10+
11+
const LABEL_COLOR: &str = "#0c0811";
12+
const STAT_COLOR: &str = "#5f3d84";
13+
14+
#[derive(Deserialize, Clone, Copy, PartialEq, Eq, ToSchema)]
15+
#[serde(rename_all = "snake_case")]
16+
pub enum StatusBadgeStat {
17+
Version,
18+
GdVersion,
19+
GeodeVersion,
20+
Downloads,
21+
}
22+
23+
#[derive(Deserialize, IntoParams)]
24+
pub struct StatusBadgeQuery {
25+
pub stat: StatusBadgeStat,
26+
}
27+
28+
#[utoipa::path(
29+
get,
30+
path = "/v1/mods/{id}/status_badge",
31+
tag = "mods",
32+
params(
33+
("id" = String, Path, description = "Mod ID"),
34+
StatusBadgeQuery
35+
),
36+
responses(
37+
(status = 302, description = "Redirect to Shields.io badge"),
38+
(status = 400, description = "Invalid stat or missing parameter"),
39+
(status = 404, description = "Mod not found")
40+
)
41+
)]
42+
#[get("/v1/mods/{id}/status_badge")]
43+
pub async fn status_badge(
44+
data: web::Data<AppData>,
45+
id: web::Path<String>,
46+
query: web::Query<StatusBadgeQuery>,
47+
) -> Result<impl Responder, ApiError> {
48+
let (stat, label, svg_path) = match query.stat {
49+
StatusBadgeStat::Version => (
50+
"payload.versions[0].version",
51+
"Version",
52+
"storage/public/shields/mod_version.svg",
53+
),
54+
StatusBadgeStat::GdVersion => (
55+
"payload.versions[0].gd.win",
56+
"Geometry Dash",
57+
"storage/public/shields/mod_gd_version.svg",
58+
),
59+
StatusBadgeStat::GeodeVersion => (
60+
"payload.versions[0].geode",
61+
"Geode",
62+
"storage/public/shields/mod_geode_version.svg",
63+
),
64+
StatusBadgeStat::Downloads => (
65+
"payload.download_count",
66+
"Downloads",
67+
"storage/public/shields/mod_downloads.svg",
68+
),
69+
};
70+
let svg = fs::read_to_string(Path::new(svg_path))
71+
.map_err(|_| ApiError::BadRequest(format!("Could not read SVG file: {}", svg_path)))?;
72+
let api_url = format!("{}/v1/mods/{}?abbreviate=true", data.app_url(), id);
73+
let mod_link = format!("{}/mods/{}", data.front_url(), id);
74+
let svg_data_url = format!("data:image/svg+xml;utf8,{}", urlencoding::encode(&svg));
75+
let shields_url = format!(
76+
"https://img.shields.io/badge/dynamic/json?url={}&query={}&label={}&labelColor={}&color={}&link={}&style=plastic&logo={}",
77+
urlencoding::encode(&api_url),
78+
urlencoding::encode(stat),
79+
label,
80+
urlencoding::encode(LABEL_COLOR),
81+
urlencoding::encode(STAT_COLOR),
82+
urlencoding::encode(&mod_link),
83+
urlencoding::encode(&svg_data_url)
84+
);
85+
Ok(HttpResponse::Found()
86+
.append_header(("Location", shields_url))
87+
.finish())
88+
}

src/endpoints/mods.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
#[derive(Deserialize, ToSchema)]
2+
pub struct ModGetQueryParams {
3+
pub abbreviate: Option<bool>,
4+
}
15
use crate::config::AppData;
26
use crate::database::repository::developers;
37
use crate::database::repository::incompatibilities;
@@ -126,6 +130,7 @@ pub async fn index(
126130
pub async fn get(
127131
data: web::Data<AppData>,
128132
id: web::Path<String>,
133+
query: web::Query<ModGetQueryParams>,
129134
auth: Auth,
130135
) -> Result<impl Responder, ApiError> {
131136
let dev = auth.developer().ok();
@@ -171,6 +176,8 @@ pub async fn get(
171176
i.modify_metadata(data.app_url(), has_extended_permissions);
172177
}
173178

179+
the_mod.set_abbreviated_download_counts(query.abbreviate.unwrap_or(false));
180+
174181
Ok(web::Json(ApiResponse {
175182
error: "".into(),
176183
payload: the_mod,

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use actix_web::{
99
use utoipa::OpenApi;
1010
use utoipa_swagger_ui::SwaggerUi;
1111

12+
mod abbreviate;
1213
mod auth;
1314
mod cli;
1415
mod config;
@@ -65,6 +66,7 @@ async fn main() -> anyhow::Result<()> {
6566
.service(endpoints::mods::create)
6667
.service(endpoints::mods::update_mod)
6768
.service(endpoints::mods::get_logo)
69+
.service(endpoints::mod_status_badge::status_badge)
6870
.service(endpoints::mod_versions::get_version_index)
6971
.service(endpoints::mod_versions::get_one)
7072
.service(endpoints::mod_versions::download_version)

src/types/models/download_count.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
use crate::abbreviate::abbreviate_number;
2+
use serde::{Serialize, Serializer};
3+
4+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5+
pub struct DownloadCount {
6+
count: i32,
7+
abbreviate: bool,
8+
}
9+
10+
impl DownloadCount {
11+
pub const fn new(count: i32) -> Self {
12+
Self {
13+
count,
14+
abbreviate: false,
15+
}
16+
}
17+
18+
pub fn set_abbreviated(&mut self, abbreviate: bool) {
19+
self.abbreviate = abbreviate;
20+
}
21+
}
22+
23+
impl From<i32> for DownloadCount {
24+
fn from(count: i32) -> Self {
25+
Self::new(count)
26+
}
27+
}
28+
29+
impl Serialize for DownloadCount {
30+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
31+
where
32+
S: Serializer,
33+
{
34+
if self.abbreviate {
35+
serializer.serialize_str(&abbreviate_number(self.count))
36+
} else {
37+
serializer.serialize_i32(self.count)
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)