ajour_core/repository/backend/
curse.rs

1use 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 /* stable */ => {
101                    remote_packages.insert(ReleaseChannel::Stable, package);
102                }
103                2 /* beta */ => {
104                    remote_packages.insert(ReleaseChannel::Beta, package);
105                }
106                3 /* alpha */ => {
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 /* stable */ => {
149                    remote_packages.insert(ReleaseChannel::Stable, package);
150                }
151                2 /* beta */ => {
152                    remote_packages.insert(ReleaseChannel::Beta, package);
153                }
154                3 /* alpha */ => {
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        // Get repo packages from fingerprint exact matches
190        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        // Remove any packages that match a fingerprint entry and update missing
208        // metadata fields with that package info
209        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")]
280/// Struct for applying curse details to an `Addon`.
281pub 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}