use std::borrow::Cow;
use std::sync::LazyLock;
use regex::{Captures, Regex};
use serde_json::Value;
use crate::VersionsPatch;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TgPost {
pub version: String,
pub build_code: Option<u32>,
}
static MESSAGE_TEXT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?s)<div[^>]*class="tgme_widget_message_text(?:\s[^"]*)?"[^>]*>(.*?)</div>"#)
.expect("valid message regex")
});
static TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new("<[^>]+>").expect("valid tag regex"));
static NEW_VERSION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)New\s+version\s*:\s*([^\s\(\)]+)(?:\s*\((\d+)\))?")
.expect("valid version regex")
});
static ENTITY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new("&(?:#([0-9]+);?|#[xX]([0-9a-fA-F]+);?|([A-Za-z]+);)").expect("valid entity regex")
});
#[must_use]
pub fn parse_tgstable(html: &str, platform: Option<&str>) -> Vec<TgPost> {
let Ok(platform) = platform
.map(|name| Regex::new(&format!(r"(?i)Telegram\s+for\s+{}\b", regex::escape(name))))
.transpose()
else {
return Vec::new();
};
MESSAGE_TEXT
.captures_iter(html)
.filter_map(|message| {
let stripped = TAG.replace_all(&message[1], "");
let text = unescape(stripped.trim());
if platform.as_ref().is_some_and(|p| !p.is_match(&text)) {
return None;
}
let found = NEW_VERSION.captures(&text)?;
Some(TgPost {
version: found[1].to_owned(),
build_code: found.get(2).and_then(|code| code.as_str().parse().ok()),
})
})
.collect()
}
#[must_use]
pub fn parse_android_tgstable(html: &str) -> Option<VersionsPatch> {
let latest = parse_tgstable(html, Some("Android")).pop()?;
Some(VersionsPatch {
android_app_version: Some(latest.version),
android_app_version_code: latest.build_code,
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_android_play(json: &str) -> Option<VersionsPatch> {
let version = non_empty(parse_json(json)?.get("version")?.as_str()?)?;
Some(VersionsPatch {
android_app_version: Some(version),
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_ios_tgstable(html: &str) -> Option<VersionsPatch> {
let latest = parse_tgstable(html, Some("iOS")).pop()?;
Some(VersionsPatch {
ios_app_version: Some(latest.version),
ios_build_number: latest.build_code,
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_ios_itunes(json: &str) -> Option<VersionsPatch> {
Some(VersionsPatch {
ios_app_version: Some(itunes_version(json)?),
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_tdesktop(json: &str) -> Option<VersionsPatch> {
Some(VersionsPatch {
desktop_app_version: Some(release_tag(json)?),
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_telegram_x(json: &str) -> Option<VersionsPatch> {
Some(VersionsPatch {
android_x_app_version: Some(release_tag(json)?),
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_macos_brew(json: &str) -> Option<VersionsPatch> {
let raw = parse_json(json)?.get("version")?.as_str()?.to_owned();
if raw.is_empty() {
return None;
}
let Some((version, build)) = raw.split_once(',') else {
return Some(VersionsPatch {
macos_app_version: Some(raw),
..VersionsPatch::default()
});
};
let build = build.trim();
Some(VersionsPatch {
macos_app_version: non_empty(version.trim()),
macos_build_number: if build.bytes().all(|b| b.is_ascii_digit()) {
build.parse().ok()
} else {
None
},
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_macos_itunes(json: &str) -> Option<VersionsPatch> {
Some(VersionsPatch {
macos_app_version: Some(itunes_version(json)?),
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_web_k(text: &str) -> Option<VersionsPatch> {
let lines: Vec<&str> = python_lines(text.trim())
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
let version_line = lines
.iter()
.find(|line| !["<<<", "===", ">>>"].iter().any(|m| line.starts_with(m)))
.or(lines.first())
.copied()
.unwrap_or("");
let version = match version_line.split_once('(') {
Some((before, _)) => before.trim(),
None => version_line,
};
Some(VersionsPatch {
web_k_version: Some(format!("{version} K")),
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_web_a(text: &str) -> Option<VersionsPatch> {
Some(VersionsPatch {
web_a_version: Some(format!("{} A", text.trim())),
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_chrome(json: &str) -> Option<VersionsPatch> {
let version = parse_json(json)?;
let major = major_version(version.pointer("/channels/Stable/version")?.as_str()?)?;
Some(VersionsPatch {
chrome_version: Some(format!("{major}.0.0.0")),
..VersionsPatch::default()
})
}
#[must_use]
pub fn parse_firefox(json: &str) -> Option<VersionsPatch> {
let version = parse_json(json)?;
let major = major_version(version.get("LATEST_FIREFOX_VERSION")?.as_str()?)?;
Some(VersionsPatch {
firefox_version: Some(format!("{major}.0")),
..VersionsPatch::default()
})
}
fn major_version(version: &str) -> Option<&str> {
let major = version.trim().split('.').next()?;
(!major.is_empty() && major.bytes().all(|b| b.is_ascii_digit())).then_some(major)
}
fn parse_json(json: &str) -> Option<Value> {
serde_json::from_str(json).ok()
}
fn release_tag(json: &str) -> Option<String> {
non_empty(
parse_json(json)?
.get("tag_name")?
.as_str()?
.trim_start_matches('v'),
)
}
fn itunes_version(json: &str) -> Option<String> {
non_empty(parse_json(json)?.pointer("/results/0/version")?.as_str()?)
}
fn non_empty(text: &str) -> Option<String> {
(!text.is_empty()).then(|| text.to_owned())
}
fn python_lines(text: &str) -> impl Iterator<Item = &str> {
text.split([
'\n', '\r', '\x0b', '\x0c', '\x1c', '\x1d', '\x1e', '\u{85}', '\u{2028}', '\u{2029}',
])
}
fn unescape(text: &str) -> Cow<'_, str> {
ENTITY.replace_all(text, |entity: &Captures<'_>| {
let code = if let Some(decimal) = entity.get(1) {
decimal.as_str().parse::<u32>().ok()
} else if let Some(hex) = entity.get(2) {
u32::from_str_radix(hex.as_str(), 16).ok()
} else {
let named = match &entity[3] {
"amp" => '&',
"lt" => '<',
"gt" => '>',
"quot" => '"',
"apos" => '\'',
"nbsp" => '\u{a0}',
_ => return entity[0].to_owned(),
};
return named.to_string();
};
code.filter(|&c| c != 0)
.and_then(char::from_u32)
.unwrap_or('\u{fffd}')
.to_string()
})
}