ajour_core/
catalog.rs

1use crate::config::Flavor;
2use crate::error::DownloadError;
3use crate::network::request_async;
4
5use chrono::prelude::*;
6use isahc::AsyncReadResponseExt;
7use serde::{Deserialize, Serialize};
8
9const CATALOG_URL: &str = "https://github.com/ajour/ajour-catalog/raw/master/catalog-2.0.json";
10
11type Etag = Option<String>;
12
13async fn get_catalog_addons_from(
14    url: &str,
15    cached_etag: Etag,
16) -> Result<Option<(Etag, Vec<CatalogAddon>)>, DownloadError> {
17    let mut headers = vec![];
18    if let Some(etag) = cached_etag.as_deref() {
19        headers.push(("If-None-Match", etag));
20    }
21
22    let mut response = request_async(url, headers, None).await?;
23
24    match response.status().as_u16() {
25        200 => {
26            log::debug!("Downloaded latest catalog from {}", url);
27
28            let etag = response
29                .headers()
30                .get("etag")
31                .and_then(|h| h.to_str().map(String::from).ok());
32
33            Ok(Some((etag, response.json::<Vec<CatalogAddon>>().await?)))
34        }
35        304 => {
36            log::debug!("Etag match, cached catalog is latest version");
37            Ok(None)
38        }
39        status => {
40            log::error!("Catalog failed to download with status: {}", status);
41            return Err(DownloadError::CatalogFailed);
42        }
43    }
44}
45
46pub(crate) async fn download_catalog(
47    cached_etag: Etag,
48) -> Result<Option<(Etag, Catalog)>, DownloadError> {
49    let response = get_catalog_addons_from(CATALOG_URL, cached_etag)
50        .await?
51        .map(|(etag, addons)| (etag, Catalog { addons }));
52
53    Ok(response)
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
57pub enum Source {
58    #[serde(alias = "curse")]
59    Curse,
60    #[serde(alias = "tukui")]
61    Tukui,
62    #[serde(alias = "wowi")]
63    WowI,
64    #[serde(alias = "townlong-yak")]
65    TownlongYak,
66    #[serde(other)]
67    Other,
68}
69
70impl std::fmt::Display for Source {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        let s = match self {
73            Source::Curse => "Curse",
74            Source::Tukui => "Tukui",
75            Source::WowI => "WowInterface",
76            Source::TownlongYak => "TownlongYak",
77
78            // This is a fallback option.
79            Source::Other => "Unknown",
80        };
81        write!(f, "{}", s)
82    }
83}
84
85#[serde(transparent)]
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Catalog {
88    pub addons: Vec<CatalogAddon>,
89}
90
91#[serde(rename_all = "camelCase")]
92#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
93pub struct GameVersion {
94    #[serde(deserialize_with = "null_to_default::deserialize")]
95    pub game_version: String,
96    pub flavor: Flavor,
97}
98
99#[serde(rename_all = "camelCase")]
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct CatalogAddon {
102    #[serde(deserialize_with = "null_to_default::deserialize")]
103    pub id: i32,
104    #[serde(deserialize_with = "null_to_default::deserialize")]
105    pub website_url: String,
106    #[serde(deserialize_with = "date_parser::deserialize")]
107    pub date_released: Option<DateTime<Utc>>,
108    #[serde(deserialize_with = "null_to_default::deserialize")]
109    pub name: String,
110    #[serde(deserialize_with = "null_to_default::deserialize")]
111    pub categories: Vec<String>,
112    #[serde(deserialize_with = "null_to_default::deserialize")]
113    pub summary: String,
114    #[serde(deserialize_with = "null_to_default::deserialize")]
115    pub number_of_downloads: u64,
116    pub source: Source,
117    #[serde(deserialize_with = "null_to_default::deserialize")]
118    #[deprecated(since = "0.4.4", note = "Please use game_versions instead")]
119    pub flavors: Vec<Flavor>,
120    #[serde(deserialize_with = "null_to_default::deserialize")]
121    pub game_versions: Vec<GameVersion>,
122}
123
124mod null_to_default {
125    use serde::{self, Deserialize, Deserializer};
126
127    pub fn deserialize<'de, D, T>(deserializer: D) -> Result<T, D::Error>
128    where
129        D: Deserializer<'de>,
130        T: Default + Deserialize<'de>,
131    {
132        let opt = Option::deserialize(deserializer)?;
133        Ok(opt.unwrap_or_default())
134    }
135}
136
137mod date_parser {
138    use chrono::prelude::*;
139    use serde::{self, Deserialize, Deserializer};
140
141    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
142    where
143        D: Deserializer<'de>,
144    {
145        let s = String::deserialize(deserializer)?;
146
147        // Curse format
148        let date = DateTime::parse_from_rfc3339(&s)
149            .map(|d| d.with_timezone(&Utc))
150            .ok();
151
152        if date.is_some() {
153            return Ok(date);
154        }
155
156        // Tukui format
157        let date = NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %T")
158            .map(|d| Utc.from_utc_datetime(&d))
159            .ok();
160
161        if date.is_some() {
162            return Ok(date);
163        }
164
165        // Handles Elvui and Tukui addons which runs in a format without HH:mm:ss.
166        let s_modified = format!("{} 00:00:00", &s);
167        let date = NaiveDateTime::parse_from_str(&s_modified, "%Y-%m-%d %T")
168            .map(|d| Utc.from_utc_datetime(&d))
169            .ok();
170
171        if date.is_some() {
172            return Ok(date);
173        }
174
175        // Handles WowI.
176        if let Ok(ts) = &s.parse::<i64>() {
177            let date = Utc.timestamp(ts / 1000, 0);
178            return Ok(Some(date));
179        }
180
181        Ok(None)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_catalog_download() {
191        async_std::task::block_on(async {
192            let catalog = download_catalog(None).await;
193
194            if let Err(e) = catalog {
195                panic!("{}", e);
196            }
197        });
198    }
199
200    #[test]
201    fn test_null_fields() {
202        let tests = [
203            r"[]",
204            r#"[{"id": null,"websiteUrl": null,"dateReleased":"2020-11-20T02:29:43.46Z","name": null,"summary": null,"numberOfDownloads": null,"categories": null,"flavors": null,"gameVersions": null,"source":"curse"}]"#,
205        ];
206
207        for test in tests.iter() {
208            serde_json::from_str::<Vec<CatalogAddon>>(test).unwrap();
209        }
210    }
211}