use crate::chart::{Chart, CoordinateSystem, EventType, HouseSystem, Latitude, Longitude, Zodiac};
use crate::normalize::normalize_cp1252_str;
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use std::time::Duration;
use thiserror::Error;
pub const BASE_URL: &str = "https://www.lunaastrology.com";
pub const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36";
#[derive(Debug, Error)]
pub enum LunaError {
#[error("JSON missing 'uniwheel' key")]
MissingUniwheel,
#[error("invalid UTC offset: {0:?}")]
InvalidOffset(String),
#[error("invalid date or time: {0:?}")]
InvalidDateTime(String),
#[error("invalid coordinate")]
InvalidCoordinate,
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("HTTP: {0}")]
Http(#[from] reqwest::Error),
#[error("HTTP client build error: {0}")]
HttpClientBuild(String),
#[error("{0} form tokens not found in page")]
FormTokensNotFound(String),
#[error("phenomenon ID not found in create response")]
PhenomIdNotFound,
}
#[derive(Debug, Clone)]
pub struct ListingRow {
pub chart_id: String,
pub name: String,
pub chart_type: String,
pub year: i16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
}
#[derive(Debug, Clone)]
pub struct CastMeta {
pub date: String,
pub time: String,
pub lat: f64,
pub lon: f64,
pub offset_str: String,
pub zodiac: String,
pub location: String,
}
#[derive(Debug, Clone)]
pub struct SidebarMeta {
pub house_system: String,
pub zodiac: String,
pub tz_abbrev: String,
pub is_lmt: bool,
pub rodden_code: String,
pub rodden_desc: String,
pub phenom_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LunaChart {
pub chart_id: String,
pub name: String,
pub chart_type: String,
pub date: String,
pub time: String,
pub lat: f64,
pub lon: f64,
pub offset_str: String,
pub location: String,
pub zodiac: String,
pub house_system: String,
pub tz_abbrev: String,
pub is_lmt: bool,
pub rodden_code: String,
pub rodden_desc: String,
pub notes: String,
}
#[must_use]
pub fn parse_listing_page(html: &str) -> Vec<ListingRow> {
let doc = Html::parse_document(html);
let sel_row = Selector::parse("tr[data-chart-url]").unwrap();
let sel_name = Selector::parse("a.font-lg").unwrap();
let sel_badge = Selector::parse(".badge").unwrap();
let sel_td = Selector::parse("td[data-sort]").unwrap();
let mut rows = Vec::new();
for row in doc.select(&sel_row) {
let chart_url = row.value().attr("data-chart-url").unwrap_or("");
let Some(chart_id) = extract_uuid(chart_url) else {
continue;
};
let name = row
.select(&sel_name)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
.unwrap_or_default();
let chart_type = row.select(&sel_badge).next().map_or_else(
|| "natal".to_string(),
|e| e.text().collect::<String>().trim().to_lowercase(),
);
let dt = row
.select(&sel_td)
.filter_map(|td| td.value().attr("data-sort"))
.find(|v| looks_like_datetime(v))
.and_then(parse_listing_datetime);
let Some((year, month, day, hour, minute, second)) = dt else {
continue;
};
rows.push(ListingRow {
chart_id,
name,
chart_type,
year,
month,
day,
hour,
minute,
second,
});
}
rows
}
fn extract_uuid(s: &str) -> Option<String> {
let idx = s.find("uniwheel=")?;
let rest = &s[idx + "uniwheel=".len()..];
if rest.len() >= 36 {
Some(rest[..36].to_string())
} else {
None
}
}
fn looks_like_datetime(s: &str) -> bool {
s.len() >= 19 && s.as_bytes().first().is_some_and(u8::is_ascii_digit) && s.contains('T')
}
fn parse_listing_datetime(s: &str) -> Option<(i16, u8, u8, u8, u8, u8)> {
let s = s.get(..19)?; let (date, time) = s.split_once('T')?;
let date_parts: Vec<&str> = date.split('-').collect();
let time_parts: Vec<&str> = time.split(':').collect();
if date_parts.len() < 3 || time_parts.len() < 3 {
return None;
}
let year: i16 = date_parts[0].parse().ok()?;
let month: u8 = date_parts[1].parse().ok()?;
let day: u8 = date_parts[2].parse().ok()?;
let hour: u8 = time_parts[0].parse().ok()?;
let minute: u8 = time_parts[1].parse().ok()?;
let second: u8 = time_parts[2].parse().ok()?;
Some((year, month, day, hour, minute, second))
}
pub fn parse_cast_json(json: &str) -> Result<CastMeta, LunaError> {
let v: serde_json::Value = serde_json::from_str(json)?;
let uw = v.get("uniwheel").ok_or(LunaError::MissingUniwheel)?;
Ok(CastMeta {
date: uw["datepicker"].as_str().unwrap_or("").to_string(),
time: uw["eventTime"].as_str().unwrap_or("").to_string(),
lat: uw["latitude"].as_f64().unwrap_or(0.0),
lon: uw["longitude"].as_f64().unwrap_or(0.0),
offset_str: uw["offset"].as_str().unwrap_or("UTC+00:00:00").to_string(),
zodiac: uw["zodiac"].as_str().unwrap_or("Tropical").to_string(),
location: uw["location"].as_str().unwrap_or("").to_string(),
})
}
pub fn parse_sidebar(html: &str) -> SidebarMeta {
let doc = Html::parse_document(html);
let lines: Vec<String> = doc
.root_element()
.text()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let house_system = find_after(&line_refs, "house system")
.unwrap_or("")
.to_string();
let zodiac = find_after(&line_refs, "zodiac").unwrap_or("").to_string();
let tz_abbrev = find_after(&line_refs, "timezone").unwrap_or("").to_string();
let is_lmt = tz_abbrev.to_uppercase().contains("LMT");
let sel_rodden = Selector::parse(r#"a[href*="rodden-rating"]"#).unwrap();
let rodden_text = doc
.select(&sel_rodden)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
.unwrap_or_default();
let (rodden_code, rodden_desc) = parse_rodden_text(&rodden_text);
let phenom_id = extract_phenom_id(html);
SidebarMeta {
house_system,
zodiac,
tz_abbrev,
is_lmt,
rodden_code,
rodden_desc,
phenom_id,
}
}
fn find_after<'a>(lines: &[&'a str], label: &str) -> Option<&'a str> {
let label = label.to_lowercase();
for (i, line) in lines.iter().enumerate() {
if line.to_lowercase() == label {
let end = (i + 4).min(lines.len());
for candidate in &lines[i + 1..end] {
let v = candidate.trim();
if !v.is_empty() && v.to_lowercase() != label {
return Some(candidate);
}
}
}
}
None
}
fn parse_rodden_text(s: &str) -> (String, String) {
let s = s.trim();
if s.is_empty() {
return (String::new(), String::new());
}
if let Some(close) = s.find(')') {
let code = s[1..close].trim().to_string();
let rest = s[close + 1..]
.trim_start_matches([' ', '-', '\u{2013}', '\u{2014}'])
.trim()
.to_string();
(code, rest)
} else {
(s.trim_matches(['(', ')']).to_string(), String::new())
}
}
#[must_use]
pub fn luna_type_to_event_type(chart_type: &str) -> EventType {
match chart_type.to_lowercase().as_str() {
"event" => EventType::Event,
"horary" => EventType::Horary,
_ => EventType::Unspecified,
}
}
#[must_use]
pub fn luna_house_system(name: &str) -> HouseSystem {
match name.to_lowercase().as_str() {
"campanus" => HouseSystem::Campanus,
"koch" => HouseSystem::Koch,
"meridian" => HouseSystem::Meridian,
"morinus" => HouseSystem::Morinus,
"placidus" => HouseSystem::Placidus,
"porphyry" => HouseSystem::Porphyry,
"regiomontanus" => HouseSystem::Regiomontanus,
"topocentric" => HouseSystem::Topocentric,
"equal" | "equal-ac" | "equal ac" => HouseSystem::Equal,
"whole sign" | "whole-sign" | "whole-sign-equal-houses" => HouseSystem::WholeSign,
"alcabitus" => HouseSystem::Alcabitius,
"zero aries" | "0-aries" | "0 aries" => HouseSystem::ZeroAries,
_ => HouseSystem::Other(0),
}
}
#[must_use]
pub fn luna_zodiac(name: &str) -> Zodiac {
match name.to_lowercase().as_str() {
"fagan-bradley" | "fagan bradley" | "fagan/bradley" => Zodiac::FaganAllen,
"lahiri" => Zodiac::Lahiri,
"deluce" | "de luce" => Zodiac::DeLuce,
"raman" => Zodiac::Raman,
"usha-shashi" | "usha shashi" => Zodiac::UshaShashi,
"krishnamurti" => Zodiac::Krishnamurti,
"djwhal-khul" | "djwhal khul" => Zodiac::DjwhalKhul,
"yukteshwar" | "sri yukteswar" => Zodiac::SriYukteswar,
_ => Zodiac::Tropical,
}
}
#[must_use]
pub fn map_rodden_rating(code: &str, desc: &str) -> Option<String> {
let code = code.trim();
let desc = desc.trim();
if code.is_empty() && desc.is_empty() {
return None;
}
let s = if !code.is_empty() && !desc.is_empty() {
format!("{code} {desc}")
} else {
code.to_string()
};
Some(s[..s.len().min(32)].to_string())
}
fn parse_offset_str(s: &str) -> Result<f64, LunaError> {
let rest = s
.strip_prefix("UTC")
.ok_or_else(|| LunaError::InvalidOffset(s.to_string()))?;
if rest.is_empty() {
return Ok(0.0);
}
let sign = if rest.starts_with('-') {
-1.0_f64
} else {
1.0_f64
};
let hms = &rest[1..];
let parts: Vec<&str> = hms.split(':').collect();
if parts.is_empty() {
return Err(LunaError::InvalidOffset(s.to_string()));
}
let h: f64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0.0);
let m: f64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0.0);
let sec: f64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0.0);
Ok(sign * (h + m / 60.0 + sec / 3600.0))
}
fn parse_date(s: &str) -> Result<(i16, u8, u8), LunaError> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() < 3 {
return Err(LunaError::InvalidDateTime(s.to_string()));
}
let year: i16 = parts[0]
.parse()
.map_err(|_| LunaError::InvalidDateTime(s.to_string()))?;
let month: u8 = parts[1]
.parse()
.map_err(|_| LunaError::InvalidDateTime(s.to_string()))?;
let day: u8 = parts[2]
.parse()
.map_err(|_| LunaError::InvalidDateTime(s.to_string()))?;
Ok((year, month, day))
}
fn parse_time(s: &str) -> (u8, u8, u8) {
let parts: Vec<&str> = s.split(':').collect();
let hour: u8 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0);
let minute: u8 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
let second: u8 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
(hour, minute, second)
}
pub fn luna_chart_to_chart(luna: &LunaChart) -> Result<Chart, LunaError> {
let (year, month, day) = parse_date(&luna.date)?;
let (hour, minute, second) = parse_time(&luna.time);
let tz_offset_hours = parse_offset_str(&luna.offset_str)?;
let latitude = Latitude::new(luna.lat).map_err(|_| LunaError::InvalidCoordinate)?;
let longitude = Longitude::new(luna.lon).map_err(|_| LunaError::InvalidCoordinate)?;
let city = luna
.location
.split(',')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let source_rating = map_rodden_rating(&luna.rodden_code, &luna.rodden_desc);
let notes = if luna.notes.is_empty() {
None
} else {
Some(luna.notes.clone())
};
Ok(Chart {
name: luna.name.clone(),
secondary_name: None,
city,
region: None,
longitude,
latitude,
year,
month,
day,
hour,
minute,
second,
tz_offset_hours,
tz_abbreviation: if luna.tz_abbrev.is_empty() {
None
} else {
Some(luna.tz_abbrev.clone())
},
is_lmt: luna.is_lmt,
event_type: luna_type_to_event_type(&luna.chart_type),
source_rating,
house_system: luna_house_system(&luna.house_system),
zodiac: luna_zodiac(&luna.zodiac),
coordinate_system: CoordinateSystem::Geocentric,
sub_charts: vec![],
notes,
})
}
#[derive(Debug, Clone)]
pub struct FormTokens {
pub csrf: String,
pub fields: String,
pub unlocked: String,
}
#[must_use]
pub fn parse_form_tokens(html: &str, action_fragment: &str) -> Option<FormTokens> {
let doc = Html::parse_document(html);
let form_sel = Selector::parse("form").expect("valid selector");
let input_sel = Selector::parse("input").expect("valid selector");
for form in doc.select(&form_sel) {
let action = form.value().attr("action").unwrap_or("");
if !action.contains(action_fragment) {
continue;
}
let mut csrf = String::new();
let mut fields = String::new();
let mut unlocked = String::new();
for input in form.select(&input_sel) {
let name = input.value().attr("name").unwrap_or("");
let value = input.value().attr("value").unwrap_or("");
match name {
"_csrfToken" => csrf = value.to_string(),
"_Token[fields]" => fields = value.to_string(),
"_Token[unlocked]" => unlocked = value.to_string(),
_ => {}
}
}
return Some(FormTokens {
csrf,
fields,
unlocked,
});
}
None
}
#[must_use]
pub fn chart_type_str(et: EventType) -> &'static str {
match et {
EventType::Horary => "horary",
EventType::Event => "event",
_ => "natal",
}
}
#[must_use]
pub fn source_id_for_rating(rating: Option<&str>) -> u32 {
let code = rating
.map_or("", |s| {
s.trim().split_ascii_whitespace().next().unwrap_or("")
})
.to_uppercase();
match code.as_str() {
"AA" => 1,
"A" => 3,
"B" => 5,
"C" => 6,
"DD" => 9,
"X" => 10,
"XX" => 12,
_ => 99,
}
}
#[must_use]
pub fn create_payload(chart: &Chart, tokens: &FormTokens) -> Vec<(String, String)> {
let location = match (&chart.city, &chart.region) {
(Some(c), Some(r)) => format!("{c}, {r}"),
(Some(c), None) => c.clone(),
(None, Some(r)) => r.clone(),
(None, None) => String::new(),
};
let date = format!("{:04}-{:02}-{:02}", chart.year, chart.month, chart.day);
let time = format!("{:02}:{:02}:{:02}", chart.hour, chart.minute, chart.second);
let name = if chart.name.len() > 100 {
&chart.name[..100]
} else {
&chart.name
};
vec![
("_csrfToken".to_string(), tokens.csrf.clone()),
("_Token[fields]".to_string(), tokens.fields.clone()),
("_Token[unlocked]".to_string(), tokens.unlocked.clone()),
("name".to_string(), name.to_string()),
(
"type".to_string(),
chart_type_str(chart.event_type).to_string(),
),
("tags".to_string(), String::new()),
("primary_radix_chart[event_date]".to_string(), date),
("primary_radix_chart[event_time]".to_string(), time),
("primary_radix_chart[location]".to_string(), location),
(
"primary_radix_chart[latitude]".to_string(),
format!("{:.6}", chart.latitude.degrees()),
),
(
"primary_radix_chart[longitude]".to_string(),
format!("{:.6}", chart.longitude.degrees()),
),
(
"primary_radix_chart[chart_source_id]".to_string(),
source_id_for_rating(chart.source_rating.as_deref()).to_string(),
),
]
}
#[must_use]
pub fn edit_payload(chart: &Chart, tokens: &FormTokens) -> Vec<(String, String)> {
let mut payload = vec![("_method".to_string(), "PUT".to_string())];
payload.extend(create_payload(chart, tokens));
payload
}
#[must_use]
pub fn delete_payload(tokens: &FormTokens) -> Vec<(String, String)> {
vec![
("_method".to_string(), "DELETE".to_string()),
("_csrfToken".to_string(), tokens.csrf.clone()),
("_Token[fields]".to_string(), tokens.fields.clone()),
("_Token[unlocked]".to_string(), tokens.unlocked.clone()),
]
}
#[must_use]
pub fn extract_phenom_id(text: &str) -> Option<String> {
let pat = "/phenomena/";
let mut pos = 0;
while let Some(idx) = text[pos..].find(pat) {
let start = pos + idx + pat.len();
let rest = &text[start..];
let after_verb = rest.find('/').map_or(rest, |i| &rest[i + 1..]);
if after_verb.len() >= 36 {
let candidate = &after_verb[..36];
if is_uuid(candidate) {
return Some(candidate.to_string());
}
}
pos = pos + idx + 1;
}
None
}
fn is_uuid(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 36
&& b[8] == b'-'
&& b[13] == b'-'
&& b[18] == b'-'
&& b[23] == b'-'
&& b.iter()
.enumerate()
.all(|(i, &c)| i == 8 || i == 13 || i == 18 || i == 23 || c.is_ascii_hexdigit())
}
#[must_use]
pub fn at_resume_point(name: &str, prefix: &str) -> bool {
name.to_lowercase().starts_with(&prefix.to_lowercase())
}
#[must_use]
pub fn candidate_status(
chart: &crate::chart::Chart,
existing: &[crate::chart::Chart],
listing_indices: &[usize],
) -> String {
match crate::consolidate::find_candidate(chart, existing) {
Some(idx) => format!("ok \u{26a0} candidate of #{}", listing_indices[idx]),
None => "ok".to_string(),
}
}
#[must_use]
pub fn needs_fetch_for_normalize(name: &str) -> bool {
name.ends_with('…') || normalize_cp1252_str(name) != name
}
pub struct LunaSession {
client: Client,
delay: Duration,
}
impl LunaSession {
pub fn new(session_cookie: &str, delay_ms: u64) -> Result<Self, LunaError> {
let cookie = format!("LUNA_ASTROLOGY_APP={session_cookie}");
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::COOKIE,
reqwest::header::HeaderValue::from_str(&cookie)
.map_err(|e| LunaError::HttpClientBuild(e.to_string()))?,
);
let client = Client::builder()
.default_headers(headers)
.user_agent(USER_AGENT)
.build()
.map_err(|e| LunaError::HttpClientBuild(e.to_string()))?;
Ok(Self {
client,
delay: Duration::from_millis(delay_ms),
})
}
fn get_text(&self, url: &str) -> Result<String, LunaError> {
Ok(self.client.get(url).send()?.error_for_status()?.text()?)
}
fn sleep(&self) {
if !self.delay.is_zero() {
std::thread::sleep(self.delay);
}
}
pub fn fetch_listing(&self) -> Result<Vec<ListingRow>, LunaError> {
let mut listing = Vec::new();
let mut page = 1u32;
loop {
let url = format!("{BASE_URL}/phenomena?limit=100&page={page}");
let html = self.get_text(&url)?;
let rows = parse_listing_page(&html);
let done = rows.len() < 100;
listing.extend(rows);
if done {
break;
}
page += 1;
self.sleep();
}
Ok(listing)
}
pub fn fetch_charts(
&self,
resume_from: Option<&str>,
normalize_scan: bool,
on_start: &dyn Fn(usize, usize, &str),
on_result: &dyn Fn(&str),
) -> Result<(Vec<crate::chart::Chart>, Vec<String>), LunaError> {
use crate::normalize::normalize_chart;
let listing = self.fetch_listing()?;
let total = listing.len();
let mut charts = Vec::new();
let mut phenom_ids = Vec::new();
let mut listing_positions: Vec<usize> = Vec::new();
let mut skipping = resume_from.is_some();
for (i, row) in listing.iter().enumerate() {
if skipping {
if at_resume_point(&row.name, resume_from.unwrap()) {
skipping = false;
} else {
on_start(i + 1, total, &row.name.chars().take(40).collect::<String>());
on_result("[skip]");
continue;
}
}
if normalize_scan && !needs_fetch_for_normalize(&row.name) {
on_start(i + 1, total, &row.name.chars().take(40).collect::<String>());
on_result("clean");
continue;
}
on_start(i + 1, total, &row.name.chars().take(40).collect::<String>());
let cast_url = format!("{BASE_URL}/charts/cast.json?uniwheel={}", row.chart_id);
let cast_json = match self.get_text(&cast_url) {
Ok(t) => t,
Err(e) => {
on_result(&format!("[!] cast.json: {e}"));
continue;
}
};
let cast = match parse_cast_json(&cast_json) {
Ok(m) => m,
Err(e) => {
on_result(&format!("[!] parse cast: {e}"));
continue;
}
};
self.sleep();
let view_url = format!("{BASE_URL}/radix-charts/view?uniwheel={}", row.chart_id);
let sidebar_html = match self.get_text(&view_url) {
Ok(t) => t,
Err(e) => {
on_result(&format!("[!] sidebar: {e}"));
continue;
}
};
let sidebar = parse_sidebar(&sidebar_html);
self.sleep();
let luna_chart = LunaChart {
chart_id: row.chart_id.clone(),
name: row.name.clone(),
chart_type: row.chart_type.clone(),
date: cast.date,
time: cast.time,
lat: cast.lat,
lon: cast.lon,
offset_str: cast.offset_str,
location: cast.location,
zodiac: cast.zodiac,
house_system: sidebar.house_system,
tz_abbrev: sidebar.tz_abbrev,
is_lmt: sidebar.is_lmt,
rodden_code: sidebar.rodden_code,
rodden_desc: sidebar.rodden_desc,
notes: String::new(),
};
let phenom_id = sidebar.phenom_id.unwrap_or_default();
match luna_chart_to_chart(&luna_chart) {
Ok(mut chart) => {
normalize_chart(&mut chart);
let status = candidate_status(&chart, &charts, &listing_positions);
on_result(&status);
charts.push(chart);
phenom_ids.push(phenom_id);
listing_positions.push(i + 1);
}
Err(e) => {
on_result(&format!("[!] convert: {e}"));
}
}
}
Ok((charts, phenom_ids))
}
pub fn create_one(&self, chart: &crate::chart::Chart) -> Result<String, LunaError> {
let add_url = format!("{BASE_URL}/phenomena/add");
let form_html = self.get_text(&add_url)?;
let tokens = parse_form_tokens(&form_html, "/phenomena/add")
.ok_or_else(|| LunaError::FormTokensNotFound("/phenomena/add".into()))?;
let payload = create_payload(chart, &tokens);
let resp = self
.client
.post(&add_url)
.form(&payload)
.send()?
.error_for_status()?;
let final_url = resp.url().as_str().to_string();
let body = resp.text()?;
extract_phenom_id(&final_url)
.or_else(|| extract_phenom_id(&body))
.ok_or(LunaError::PhenomIdNotFound)
}
pub fn edit_one(&self, chart: &crate::chart::Chart, phenom_id: &str) -> Result<(), LunaError> {
let edit_url = format!("{BASE_URL}/phenomena/edit/{phenom_id}");
let form_html = self.get_text(&edit_url)?;
let tokens = parse_form_tokens(&form_html, &format!("/phenomena/edit/{phenom_id}"))
.ok_or_else(|| LunaError::FormTokensNotFound(format!("/phenomena/edit/{phenom_id}")))?;
let payload = edit_payload(chart, &tokens);
self.client
.post(&edit_url)
.form(&payload)
.send()?
.error_for_status()?;
Ok(())
}
pub fn delete_phenom(&self, phenom_id: &str) -> Result<(), LunaError> {
let edit_url = format!("{BASE_URL}/phenomena/edit/{phenom_id}");
let form_html = self.get_text(&edit_url)?;
let tokens = parse_form_tokens(&form_html, &format!("/phenomena/edit/{phenom_id}"))
.ok_or_else(|| LunaError::FormTokensNotFound(format!("/phenomena/edit/{phenom_id}")))?;
let payload = delete_payload(&tokens);
let delete_url = format!("{BASE_URL}/phenomena/delete/{phenom_id}");
self.client
.post(&delete_url)
.form(&payload)
.send()?
.error_for_status()?;
Ok(())
}
pub fn write_charts(
&self,
charts: &[crate::chart::Chart],
phenom_ids: &[String],
on_start: &dyn Fn(usize, usize, &str),
on_result: &dyn Fn(&str),
) -> Result<(), LunaError> {
let total = charts.len();
for (i, chart) in charts.iter().enumerate() {
on_start(
i + 1,
total,
&chart.name.chars().take(40).collect::<String>(),
);
let pid = phenom_ids.get(i).map_or("", String::as_str);
if pid.is_empty() {
match self.create_one(chart) {
Ok(id) => on_result(&format!("created {id}")),
Err(e) => on_result(&format!("[!] create: {e}")),
}
} else {
match self.edit_one(chart, pid) {
Ok(()) => on_result(&format!("edited {pid}")),
Err(e) => on_result(&format!("[!] edit: {e}")),
}
}
self.sleep();
}
Ok(())
}
}