1#![doc(
16 html_logo_url = "https://tanepiper.github.io/unbanished/assets/un-banished-icon.svg",
17 html_favicon_url = "https://tanepiper.github.io/unbanished/favicon.svg"
18)]
19
20use serde_json::Value;
21
22#[cfg(feature = "native")]
23pub mod native;
24
25pub const SITE: &str = "https://banishedinfo.com";
27
28pub fn user_agent(version: &str) -> String {
30 format!("Un-Banished/{version} (+https://github.com/tanepiper/unbanished)")
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Sort {
36 Featured,
38 Downloads,
40 Trending,
42 Recent,
44}
45
46impl Sort {
47 fn value(self) -> &'static str {
48 match self {
49 Sort::Featured => "overview",
50 Sort::Downloads => "downloads",
51 Sort::Trending => "downloads24",
52 Sort::Recent => "recent",
53 }
54 }
55}
56
57pub const SECTIONS: [(&str, &str); 8] = [
59 ("", "All"),
60 ("audio", "Audio"),
61 ("buildings", "Buildings"),
62 ("gameplay", "Gameplay"),
63 ("maps", "Maps"),
64 ("textures", "Textures"),
65 ("translations", "Translations"),
66 ("translated-mods", "Translated mods"),
67];
68
69#[derive(Debug, Clone, PartialEq)]
71pub struct Mod {
72 pub id: u32,
74 pub url: String,
76 pub title: String,
78 pub author: String,
80 pub info: String,
82 pub version: String,
84 pub downloads: u64,
86 pub rating: u8,
88 pub thumbnail: Option<String>,
90 pub created: String,
92}
93
94impl Mod {
95 pub fn with_id(id: u32) -> Mod {
97 Mod {
98 id,
99 url: id.to_string(),
100 title: format!("mod {id}"),
101 author: String::new(),
102 info: String::new(),
103 version: String::new(),
104 downloads: 0,
105 rating: 0,
106 thumbnail: None,
107 created: String::new(),
108 }
109 }
110
111 pub fn page(&self) -> String {
113 format!("{SITE}/mods/view/{}", self.url)
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Version {
120 pub label: String,
122 pub file: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Error {
129 Network(String),
131 Status(u16),
133 Answer(String),
135 Install(String),
137}
138
139impl std::fmt::Display for Error {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 Error::Network(m) => write!(f, "could not reach banishedinfo.com: {m}"),
143 Error::Status(s) => write!(f, "banishedinfo.com answered {s}"),
144 Error::Answer(m) => write!(f, "unexpected answer from banishedinfo.com: {m}"),
145 Error::Install(m) => write!(f, "{m}"),
146 }
147 }
148}
149
150impl std::error::Error for Error {}
151
152fn encode(text: &str) -> String {
154 text.bytes()
155 .map(|b| match b {
156 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
157 (b as char).to_string()
158 }
159 b' ' => "+".to_string(),
160 _ => format!("%{b:02X}"),
161 })
162 .collect()
163}
164
165pub fn list_url(sort: Sort, section: &str, start: u32, count: u32) -> String {
167 format!(
168 "{SITE}/mods/ajax?action=mods&start={start}&count={count}§ion={}&sort={}",
169 encode(section),
170 sort.value()
171 )
172}
173
174pub fn search_url(text: &str) -> String {
176 format!("{SITE}/search?query={}", encode(text.trim()))
177}
178
179fn count(v: &Value) -> u64 {
181 if let Some(n) = v.as_u64() {
182 return n;
183 }
184 v.as_str()
185 .map(|s| s.chars().take_while(|c| c.is_ascii_digit() || *c == ','))
186 .map(|c| c.filter(char::is_ascii_digit).collect::<String>())
187 .and_then(|s| s.parse().ok())
188 .unwrap_or(0)
189}
190
191fn text(v: &Value) -> String {
192 match v {
193 Value::String(s) => s.trim().to_string(),
194 Value::Number(n) => n.to_string(),
195 _ => String::new(),
196 }
197}
198
199fn mod_of(v: &Value) -> Option<Mod> {
200 let url = text(&v["url"]);
201 let id = count(&v["mod_id"]) as u32;
202 if url.is_empty() || id == 0 {
203 return None;
204 }
205 let thumb = text(&v["thumb"]);
206 Some(Mod {
207 id,
208 url,
209 title: unescape(&text(&v["title"])),
210 author: unescape(&text(&v["author"])),
211 info: unescape(&text(&v["info"])),
212 version: text(&v["version"]),
213 downloads: count(&v["downloads"]),
214 rating: count(&v["rating"]).min(100) as u8,
215 thumbnail: (!thumb.is_empty()).then(|| {
216 if thumb.starts_with('/') {
217 format!("{SITE}{thumb}")
218 } else {
219 thumb
220 }
221 }),
222 created: text(&v["created"]),
223 })
224}
225
226pub fn parse_list(body: &[u8]) -> Result<Vec<Mod>, Error> {
228 let v: Value = serde_json::from_slice(body).map_err(|e| Error::Answer(e.to_string()))?;
229 let list = v
230 .as_array()
231 .ok_or_else(|| Error::Answer("the mod list is not a list".into()))?;
232 Ok(list.iter().filter_map(mod_of).collect())
233}
234
235pub fn parse_search(page: &str) -> Result<Vec<Mod>, Error> {
237 let Some(start) = page.find("var mods = ") else {
238 return Ok(Vec::new());
240 };
241 let rest = &page[start + "var mods = ".len()..];
242 let mut stream = serde_json::Deserializer::from_str(rest).into_iter::<Value>();
243 let v = stream
244 .next()
245 .ok_or_else(|| Error::Answer("no results list".into()))?
246 .map_err(|e| Error::Answer(e.to_string()))?;
247 Ok(v.as_array()
248 .map(|a| a.iter().filter_map(mod_of).collect())
249 .unwrap_or_default())
250}
251
252fn unescape(s: &str) -> String {
254 s.replace("&", "&")
255 .replace(""", "\"")
256 .replace("'", "'")
257 .replace("'", "'")
258 .replace("<", "<")
259 .replace(">", ">")
260 .replace("ñ", "ñ")
261 .replace(" ", " ")
262}
263
264pub fn parse_versions(page: &str, id: u32) -> Vec<Version> {
267 let prefix = format!("/mods/download/{id}-");
268 let mut out: Vec<Version> = Vec::new();
269 let mut rest = page;
270 while let Some(i) = rest.find(&prefix) {
271 rest = &rest[i + prefix.len()..];
272 let Some((slug, after)) = rest.split_once('/') else {
273 break;
274 };
275 let n: String = after.chars().take_while(char::is_ascii_digit).collect();
276 let label = after[n.len()..]
277 .strip_prefix("\">Download version ")
278 .and_then(|t| t.split('<').next())
279 .map(|t| unescape(t.trim()));
280 if let (false, false, Some(label)) = (n.is_empty(), slug.contains(['"', '<', ' ']), label) {
281 let file = format!("{SITE}/mods/file/{id}-{slug}/{n}");
282 if !out.iter().any(|v| v.file == file) {
283 out.push(Version { label, file });
284 }
285 }
286 }
287 out
288}
289
290pub fn file_name(url: &str) -> String {
292 url.split(['?', '#'])
293 .next()
294 .and_then(|u| u.rsplit('/').next())
295 .map(|n| n.replace("%20", " "))
296 .unwrap_or_default()
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn addresses() {
305 assert_eq!(
306 list_url(Sort::Downloads, "", 20, 20),
307 "https://banishedinfo.com/mods/ajax?action=mods&start=20&count=20§ion=&sort=downloads"
308 );
309 assert_eq!(
310 search_url(" more terrain&x "),
311 "https://banishedinfo.com/search?query=more+terrain%26x"
312 );
313 assert!(user_agent("0.2.0").starts_with("Un-Banished/0.2.0 (+https://"));
314 }
315
316 const ITEM: &str = r#"{"mod_id":679,"title":"CC New Frontier & More","info":"A complete expansion.",
317 "author":"BlackLiquid","downloads":"291,278 downloads","rating":98,"version":1.55,
318 "url":"679-CC-New-Frontier-BlackLiquid","created":"2014-10-26 05:07:55","thumb":"/images/mod-thumbs/213.png"}"#;
319
320 #[test]
321 fn reads_the_list() {
322 let body = format!("[{ITEM},{{\"mod_id\":0}}]");
323 let mods = parse_list(body.as_bytes()).unwrap();
324 assert_eq!(mods.len(), 1);
325 let m = &mods[0];
326 assert_eq!((m.id, m.downloads, m.rating), (679, 291_278, 98));
327 assert_eq!(m.title, "CC New Frontier & More");
328 assert_eq!(m.version, "1.55");
329 assert_eq!(
330 m.thumbnail.as_deref(),
331 Some("https://banishedinfo.com/images/mod-thumbs/213.png")
332 );
333 assert_eq!(
334 m.page(),
335 "https://banishedinfo.com/mods/view/679-CC-New-Frontier-BlackLiquid"
336 );
337 assert_eq!(parse_list(b"[]").unwrap(), Vec::new());
338 }
339
340 #[test]
341 fn reads_search_results_from_the_page() {
342 let page = format!("<script>var mods = [{ITEM}];\nfunction f() {{}}</script>");
343 assert_eq!(parse_search(&page).unwrap()[0].id, 679);
344 assert!(parse_search("<html>no results</html>").unwrap().is_empty());
345 }
346
347 #[test]
348 fn reads_versions_newest_first() {
349 let page = r#"<a href="/mods/download/34-Better-Schools/828" class="download">Download mod</a>
350 <a href="/mods/download/34-Better-Schools/828">Download version 1.1</a>
351 <a href="/mods/download/34-Better-Schools/99">Download version 1.0</a>"#;
352 let v = parse_versions(page, 34);
353 assert_eq!(v.len(), 2);
354 assert_eq!(v[0].label, "1.1");
355 assert_eq!(
356 v[0].file,
357 "https://banishedinfo.com/mods/file/34-Better-Schools/828"
358 );
359 assert_eq!(v[1].label, "1.0");
360 assert_eq!(
361 file_name("https://banishedinfo.com/mods/file-download/264/XP-Schools.pkm"),
362 "XP-Schools.pkm"
363 );
364 }
365}