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 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 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 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 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 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}