ajour_core/repository/backend/
curse.rs1use super::*;
2use crate::config::Flavor;
3use crate::error::DownloadError;
4use crate::network::{post_json_async, request_async};
5use crate::repository::{ReleaseChannel, RemotePackage, RepositoryKind, RepositoryPackage};
6use crate::utility::{regex_html_tags_to_newline, regex_html_tags_to_space, truncate};
7
8use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use isahc::prelude::*;
11use serde::{Deserialize, Serialize};
12
13use std::collections::HashMap;
14
15const API_ENDPOINT: &str = "https://addons-ecs.forgesvc.net/api/v2";
16const FINGERPRINT_API_ENDPOINT: &str = "https://hub.wowup.io/curseforge/addons/fingerprint";
17
18#[derive(Debug, Clone)]
19pub struct Curse {
20 pub id: String,
21 pub flavor: Flavor,
22}
23
24#[async_trait]
25impl Backend for Curse {
26 async fn get_metadata(&self) -> Result<RepositoryMetadata, RepositoryError> {
27 let id = self
28 .id
29 .parse::<i32>()
30 .map_err(|_| RepositoryError::CurseIdConversion {
31 id: self.id.clone(),
32 })?;
33
34 let packages: Vec<Package> = fetch_remote_packages_by_ids(&[id]).await?;
35
36 let package = packages
37 .into_iter()
38 .next()
39 .ok_or(RepositoryError::CurseMissingPackage {
40 id: self.id.clone(),
41 })?;
42
43 let metadata = metadata_from_curse_package(self.flavor, package);
44
45 Ok(metadata)
46 }
47
48 async fn get_changelog(
49 &self,
50 file_id: Option<i64>,
51 _tag_name: Option<String>,
52 ) -> Result<Option<String>, RepositoryError> {
53 let file_id = file_id.ok_or(RepositoryError::CurseChangelogFileId)?;
54
55 let url = format!(
56 "{}/addon/{}/file/{}/changelog",
57 API_ENDPOINT, self.id, file_id
58 );
59
60 let mut resp = request_async(&url, vec![], None).await?;
61
62 if resp.status().is_success() {
63 let changelog: String = resp.text().await?;
64
65 let c = regex_html_tags_to_newline()
66 .replace_all(&changelog, "\n")
67 .to_string();
68 let c = regex_html_tags_to_space().replace_all(&c, "").to_string();
69 let c = truncate(&c, 2500).to_string();
70
71 return Ok(Some(c));
72 }
73
74 Ok(None)
75 }
76}
77
78pub(crate) fn metadata_from_curse_package(flavor: Flavor, package: Package) -> RepositoryMetadata {
79 let mut remote_packages = HashMap::new();
80
81 for file in package.latest_files.iter() {
82 let game_version_flavor = file.game_version_flavor.as_ref();
83 if !file.is_alternate && game_version_flavor == Some(&flavor.curse_format()) {
84 let version = file.display_name.clone();
85 let download_url = file.download_url.clone();
86 let date_time = DateTime::parse_from_rfc3339(&file.file_date)
87 .map(|d| d.with_timezone(&Utc))
88 .ok();
89 let modules = file.modules.iter().map(|m| m.foldername.clone()).collect();
90
91 let package = RemotePackage {
92 version,
93 download_url,
94 date_time,
95 file_id: Some(file.id),
96 modules,
97 };
98
99 match file.release_type {
100 1 => {
101 remote_packages.insert(ReleaseChannel::Stable, package);
102 }
103 2 => {
104 remote_packages.insert(ReleaseChannel::Beta, package);
105 }
106 3 => {
107 remote_packages.insert(ReleaseChannel::Alpha, package);
108 }
109 _ => ()
110 };
111 }
112 }
113
114 let mut metadata = RepositoryMetadata::empty();
115 metadata.remote_packages = remote_packages;
116 metadata.title = Some(package.name.clone());
117 metadata.website_url = Some(package.website_url.clone());
118 metadata.changelog_url = Some(format!("{}/files", package.website_url));
119
120 metadata
121}
122
123pub(crate) fn metadata_from_fingerprint_info(
124 flavor: Flavor,
125 info: &AddonFingerprintInfo,
126) -> RepositoryMetadata {
127 let mut remote_packages = HashMap::new();
128
129 for file in info.latest_files.iter() {
130 let game_version_flavor = file.game_version_flavor.as_ref();
131 if !file.is_alternate && game_version_flavor == Some(&flavor.curse_format()) {
132 let version = file.display_name.clone();
133 let download_url = file.download_url.clone();
134 let date_time = DateTime::parse_from_rfc3339(&file.file_date)
135 .map(|d| d.with_timezone(&Utc))
136 .ok();
137 let modules = file.modules.iter().map(|m| m.foldername.clone()).collect();
138
139 let package = RemotePackage {
140 version,
141 download_url,
142 date_time,
143 file_id: Some(file.id),
144 modules,
145 };
146
147 match file.release_type {
148 1 => {
149 remote_packages.insert(ReleaseChannel::Stable, package);
150 }
151 2 => {
152 remote_packages.insert(ReleaseChannel::Beta, package);
153 }
154 3 => {
155 remote_packages.insert(ReleaseChannel::Alpha, package);
156 }
157 _ => ()
158 };
159 }
160 }
161
162 let version = Some(info.file.display_name.clone());
163 let file_id = Some(info.file.id);
164 let game_version = info.file.game_version.get(0).cloned();
165
166 let mut metadata = RepositoryMetadata::empty();
167 metadata.remote_packages = remote_packages;
168 metadata.version = version;
169 metadata.file_id = file_id;
170 metadata.game_version = game_version;
171
172 metadata
173}
174
175pub(crate) async fn batch_fetch_repo_packages(
176 flavor: Flavor,
177 curse_ids: &[i32],
178 fingerprint_info: Option<&FingerprintInfo>,
179) -> Result<Vec<RepositoryPackage>, DownloadError> {
180 let mut curse_repo_packages = vec![];
181
182 if curse_ids.is_empty() {
183 return Ok(curse_repo_packages);
184 }
185
186 let mut curse_packages = curse::fetch_remote_packages_by_ids(&curse_ids).await?;
187
188 if let Some(fingerprint_info) = fingerprint_info {
189 curse_repo_packages.extend(
191 fingerprint_info
192 .exact_matches
193 .iter()
194 .map(|info| {
195 (
196 info.id.to_string(),
197 curse::metadata_from_fingerprint_info(flavor, info),
198 )
199 })
200 .filter_map(|(id, metadata)| {
201 RepositoryPackage::from_repo_id(flavor, RepositoryKind::Curse, id)
202 .map(|r| r.with_metadata(metadata))
203 .ok()
204 }),
205 );
206
207 curse_repo_packages.iter_mut().for_each(|r| {
210 if let Some(idx) = curse_packages.iter().position(|p| p.id.to_string() == r.id) {
211 let package = curse_packages.remove(idx);
212
213 r.metadata.title = Some(package.name.clone());
214 r.metadata.website_url = Some(package.website_url.clone());
215 r.metadata.changelog_url = Some(format!("{}/files", package.website_url));
216 }
217 });
218 }
219
220 curse_repo_packages.extend(
221 curse_packages
222 .into_iter()
223 .map(|package| {
224 (
225 package.id.to_string(),
226 curse::metadata_from_curse_package(flavor, package),
227 )
228 })
229 .filter_map(|(id, metadata)| {
230 RepositoryPackage::from_repo_id(flavor, RepositoryKind::Curse, id)
231 .map(|r| r.with_metadata(metadata))
232 .ok()
233 }),
234 );
235
236 Ok(curse_repo_packages)
237}
238
239pub(crate) async fn fetch_remote_packages_by_fingerprint(
240 fingerprints: &[u32],
241) -> Result<FingerprintInfo, DownloadError> {
242 let mut resp = post_json_async(
243 FINGERPRINT_API_ENDPOINT,
244 FingerprintData {
245 fingerprints: fingerprints.to_owned(),
246 },
247 vec![],
248 None,
249 )
250 .await?;
251 if resp.status().is_success() {
252 let fingerprint_info = resp.json().await?;
253 Ok(fingerprint_info)
254 } else {
255 Err(DownloadError::InvalidStatusCode {
256 code: resp.status(),
257 url: FINGERPRINT_API_ENDPOINT.to_owned(),
258 })
259 }
260}
261
262pub(crate) async fn fetch_remote_packages_by_ids(
263 curse_ids: &[i32],
264) -> Result<Vec<Package>, DownloadError> {
265 let url = format!("{}/addon", API_ENDPOINT);
266 let mut resp = post_json_async(&url, curse_ids, vec![], None).await?;
267 if resp.status().is_success() {
268 let packages = resp.json().await?;
269 Ok(packages)
270 } else {
271 Err(DownloadError::InvalidStatusCode {
272 code: resp.status(),
273 url,
274 })
275 }
276}
277
278#[derive(Clone, Debug, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct Package {
282 pub id: i32,
283 pub name: String,
284 pub website_url: String,
285 pub latest_files: Vec<File>,
286 pub date_created: DateTime<Utc>,
287 pub date_modified: DateTime<Utc>,
288 pub date_released: DateTime<Utc>,
289}
290
291#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
292#[serde(rename_all = "camelCase")]
293pub struct File {
294 pub id: i64,
295 pub display_name: String,
296 pub file_name: String,
297 pub file_date: String,
298 pub download_url: String,
299 pub release_type: u32,
300 pub game_version_flavor: Option<String>,
301 pub modules: Vec<Module>,
302 pub is_alternate: bool,
303 pub game_version: Vec<String>,
304}
305
306#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct Module {
309 pub foldername: String,
310 pub fingerprint: u32,
311 #[serde(rename = "type")]
312 pub type_field: i64,
313}
314
315#[derive(Debug, Clone, PartialEq, Deserialize)]
316#[serde(rename_all = "camelCase")]
317pub struct GameInfo {
318 pub id: i64,
319 pub name: String,
320 pub slug: String,
321 pub date_modified: String,
322 pub file_parsing_rules: Vec<FileParsingRule>,
323 pub category_sections: Vec<CategorySection>,
324}
325
326#[derive(Debug, Clone, PartialEq, Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct FileParsingRule {
329 pub comment_strip_pattern: String,
330 pub file_extension: String,
331 pub inclusion_pattern: String,
332 pub game_id: i64,
333 pub id: i64,
334}
335
336#[derive(Debug, Clone, PartialEq, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct CategorySection {
339 pub id: i64,
340 pub game_id: i64,
341 pub name: String,
342 pub package_type: i64,
343 pub path: String,
344 pub initial_inclusion_pattern: String,
345 pub extra_include_pattern: String,
346 pub game_category_id: i64,
347}
348
349#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct FingerprintInfo {
352 pub exact_matches: Vec<AddonFingerprintInfo>,
353 pub partial_matches: Vec<AddonFingerprintInfo>,
354}
355
356#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct AddonFingerprintInfo {
359 pub id: i32,
360 pub file: File,
361 pub latest_files: Vec<File>,
362}
363
364#[derive(Serialize)]
365struct FingerprintData {
366 fingerprints: Vec<u32>,
367}