use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GuideData { pub name: String, pub gid: String, pub effect: String, pub recommended_level: Option, pub combat_legend: Vec, pub resources: Vec, pub sections: Vec
, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CombatType { pub name: String, pub column: usize, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Resource { pub quantity: u32, pub name: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Section { pub name: String, pub items: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type")] pub enum SectionItem { Quest(QuestItem), Instruction(InstructionItem), Group(GroupItem), } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct QuestItem { pub name: String, pub completed: bool, pub combat_indicators: Vec, pub note: Option, pub url: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CombatIndicator { pub combat_type: String, pub count: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct InstructionItem { pub text: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GroupItem { pub note: Option, pub quests: Vec, } const SHEET_ID: &str = "1uL7svJ0E0MjhqHVLU7O4Q8v7iGwPd4bsI9qV-Pdhdds"; pub static TABS: &[(&str, &str)] = &[ ("474870200", "Dofawa"), ("743703882", "Dofus Argenté"), ("103963898", "Dofus Cawotte"), ("1075294690", "Dokoko"), ("1567240526", "Dofus des Veilleurs"), ("1011508069", "Dofus Emeraude"), ("2045137654", "Dofus Pourpre"), ("1967508888", "Domakuro"), ("1382359191", "Dorigami"), ("1413546794", "Dofus Turquoise"), ("1641656252", "Dofus des Glaçes"), ("953522228", "Dofus Abyssal"), ("818597042", "Dofoozbz"), ("1021129660", "Dofus Nébuleux"), ("595670723", "Dofus Vulbis"), ("544349966", "Dofus Tacheté"), ("1150302145", "Dofus Forgelave"), ("882278553", "Dofus Ebène"), ("200570588", "Dofus Ivoire"), ("1209269839", "Dofus Ocre"), ("462784268", "Dofus Argenté Scintillant"), ("1543573905", "Dofus Cauchemar"), ("1007491889", "Dom de Pin"), ("1047555165", "Dofus Sylvestre"), ("2105601828", "Dofus Cacao"), ("474510463", "Dokille"), ("62476099", "Dolmanax"), ("1873654554", "Dotruche"), ("360188709", "Dofus Kaliptus"), ]; fn make_client() -> Result { reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| e.to_string()) } pub fn fetch_csv(gid: &str) -> Result { let url = format!( "https://docs.google.com/spreadsheets/d/{}/export?format=csv&gid={}", SHEET_ID, gid ); make_client()? .get(&url) .send() .map_err(|e| e.to_string())? .text() .map_err(|e| e.to_string()) } /// Fetch the sheet edit page HTML and extract quest_name → url pairs. /// Pattern in Google Sheets JS: LS{url}]]LS{name}]]F pub fn fetch_quest_links(gid: &str) -> std::collections::HashMap { let url = format!( "https://docs.google.com/spreadsheets/d/{}/edit?gid={}", SHEET_ID, gid ); let html = match make_client().and_then(|c| c.get(&url).send().map_err(|e| e.to_string())).and_then(|r| r.text().map_err(|e| e.to_string())) { Ok(h) => h, Err(_) => return std::collections::HashMap::new(), }; let mut map = std::collections::HashMap::new(); // Regex-free parsing: find all "LS{url}]]LS{name}]]F" occurrences let marker = "LS"; let end_marker = "]]LS"; let end_f = "]]F"; let mut pos = 0; while let Some(start) = html[pos..].find("LShttps://www.dofuspourlesnoobs.com/") { let abs = pos + start + 2; // skip "LS" // Find end of URL if let Some(url_end) = html[abs..].find("]]LS") { let quest_url = html[abs..abs + url_end].to_string(); let name_start = abs + url_end + 4; // skip "]]LS" if let Some(name_end) = html[name_start..].find("]]F") { let raw_name = &html[name_start..name_start + name_end]; // Decode \\u00XX unicode escapes let name = decode_unicode_escapes(raw_name); map.entry(name).or_insert(quest_url); } pos = abs + url_end + 4; } else { break; } } let _ = (marker, end_marker, end_f); map } fn slugify(name: &str) -> String { let mut slug = String::with_capacity(name.len()); for c in name.chars() { if c.is_ascii_alphanumeric() { slug.push(c.to_ascii_lowercase()); } else { // apostrophes, spaces, accents, punctuation all become hyphens if !slug.ends_with('-') { slug.push('-'); } } } let slug = slug.trim_matches('-').to_string(); format!("https://www.dofuspourlesnoobs.com/{}.html", slug) } fn decode_unicode_escapes(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { if c == '\\' { match chars.next() { Some('u') => { let hex: String = chars.by_ref().take(4).collect(); if let Ok(n) = u32::from_str_radix(&hex, 16) { if let Some(decoded) = char::from_u32(n) { result.push(decoded); continue; } } result.push_str("\\u"); result.push_str(&hex); } // Google Sheets double-escapes: \\u0027 → ' Some('\\') => { if chars.peek() == Some(&'u') { chars.next(); // consume 'u' let hex: String = chars.by_ref().take(4).collect(); if let Ok(n) = u32::from_str_radix(&hex, 16) { if let Some(decoded) = char::from_u32(n) { result.push(decoded); continue; } } result.push_str("\\u"); result.push_str(&hex); } else { result.push('\\'); } } Some(other) => { result.push('\\'); result.push(other); } None => result.push('\\'), } } else { result.push(c); } } result } fn get_cell(row: &[String], col: usize) -> &str { row.get(col).map(|s| s.trim()).unwrap_or("") } fn find_checkbox_col(row: &[String]) -> Option<(usize, bool)> { for (i, cell) in row.iter().enumerate() { let v = cell.trim().to_uppercase(); if v == "FALSE" || v == "TRUE" { return Some((i, v == "TRUE")); } } None } fn parse_level_from_cell(cell: &str) -> Option { // Only parse from the specific cell containing "niveau recommandé", not the full row let lower = cell.to_lowercase(); if lower.contains("niveau recommandé") || lower.contains("niveau recommande") { // Extract only the number immediately after the colon/space if let Some(colon) = lower.find(':') { let after = lower[colon + 1..].trim_start(); let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); return digits.parse().ok(); } } None } fn is_resource_row(row: &[String]) -> Option<(u32, String)> { // Resources live in the right portion of the sheet (cols 36+) // Pattern: quantity in some col, empty cols, then name for start_col in 36..row.len().saturating_sub(4) { let qty_str = row.get(start_col).map(|s| s.trim()).unwrap_or(""); if qty_str.is_empty() { continue; } if let Ok(qty) = qty_str.parse::() { // Look for the name within the next few cols for name_col in (start_col + 1)..=(start_col + 5).min(row.len().saturating_sub(1)) { let name = row.get(name_col).map(|s| s.trim()).unwrap_or(""); if !name.is_empty() && !name.to_uppercase().contains("KAMAS") || !name.is_empty() { // Check that cols between qty and name are empty let all_empty = ((start_col + 1)..name_col) .all(|c| row.get(c).map(|s| s.trim()).unwrap_or("").is_empty()); if all_empty { return Some((qty, name.to_string())); } } } } } None } fn parse_combat_indicators(row: &[String], legend: &[CombatType], checkbox_col: usize) -> Vec { let mut indicators = Vec::new(); for ct in legend { // Skip the checkbox column and the quest name column — they are not combat indicators if ct.column <= checkbox_col + 1 { continue; } let cell = get_cell(row, ct.column); // Skip empty cells and boolean-looking values if cell.is_empty() || cell.eq_ignore_ascii_case("false") || cell.eq_ignore_ascii_case("true") { continue; } indicators.push(CombatIndicator { combat_type: ct.name.clone(), count: cell.to_string(), }); } indicators } fn extract_note(row: &[String], name_col: usize, legend: &[CombatType]) -> Option { // Note is typically in the last significant column after the combat indicators // Search for non-empty cell after col name_col+1, skipping combat indicator cols let combat_cols: std::collections::HashSet = legend.iter().map(|c| c.column).collect(); let max_search = row.len().min(36); for col in (name_col + 1)..max_search { let cell = get_cell(row, col); if !cell.is_empty() && !combat_cols.contains(&col) { // Likely a note if !cell.eq_ignore_ascii_case("false") && !cell.eq_ignore_ascii_case("true") { return Some(cell.to_string()); } } } None } pub fn parse_guide(gid: &str, name: &str, csv: &str) -> GuideData { parse_guide_with_links(gid, name, csv, &std::collections::HashMap::new()) } pub fn parse_guide_with_links( gid: &str, name: &str, csv: &str, links: &std::collections::HashMap, ) -> GuideData { let mut reader = csv::ReaderBuilder::new() .has_headers(false) .flexible(true) .from_reader(csv.as_bytes()); let rows: Vec> = reader .records() .filter_map(|r| r.ok()) .map(|r| r.iter().map(|s| s.to_string()).collect()) .collect(); let mut effect = String::new(); let mut recommended_level: Option = None; let mut resources: Vec = Vec::new(); let mut combat_legend: Vec = Vec::new(); let mut sections: Vec
= Vec::new(); // Phase 1: Extract effect (first non-empty cell in col 17+ within first 10 rows) for row in rows.iter().take(8) { for col in 14..row.len() { let cell = get_cell(row, col); if !cell.is_empty() && cell.len() > 20 && !cell.contains("Multiplicateur") { effect = cell.to_string(); break; } } if !effect.is_empty() { break; } } // Phase 2: Scan entire sheet let mut in_quest_area = false; let mut current_section = Section { name: "Prérequis".to_string(), items: Vec::new() }; let mut pending_group: Option<(Option, Vec)> = None; let mut legend_row_idx: Option = None; for (row_idx, row) in rows.iter().enumerate() { // Extract resources from right side if let Some((qty, rname)) = is_resource_row(row) { if !rname.is_empty() { resources.push(Resource { quantity: qty, name: rname }); } } // Look for recommended level — check each cell individually to avoid cross-cell digit merging if recommended_level.is_none() { for cell in row.iter() { if let Some(lvl) = parse_level_from_cell(cell.trim()) { recommended_level = Some(lvl); break; } } } // Detect legend row (contains "Bashing" or "bash" or "Combat solo") let row_text_low: String = row.iter().map(|s| s.to_lowercase()).collect::>().join("|"); if row_text_low.contains("bash") || row_text_low.contains("combat solo") { legend_row_idx = Some(row_idx); combat_legend.clear(); for (col, cell) in row.iter().enumerate() { let t = cell.trim(); if !t.is_empty() && col < 36 { combat_legend.push(CombatType { name: t.to_string(), column: col }); } } // Check next row for secondary legend entries if let Some(next_row) = rows.get(row_idx + 1) { let has_checkbox = find_checkbox_col(next_row).is_some(); if !has_checkbox { for (col, cell) in next_row.iter().enumerate() { let t = cell.trim(); if !t.is_empty() && col < 36 && !combat_legend.iter().any(|c| c.column == col) { combat_legend.push(CombatType { name: t.to_string(), column: col }); } } } } } // Detect section markers: "Prérequis", "Les quêtes" for col in 0..row.len().min(5) { let cell = get_cell(row, col); if cell == "Les quêtes" || cell == "Prérequis" || cell == "Les quetes" { // Flush pending group if let Some((note, quests)) = pending_group.take() { if !quests.is_empty() { current_section.items.push(SectionItem::Group(GroupItem { note, quests })); } } if !current_section.items.is_empty() || !sections.is_empty() { let prev = std::mem::replace(&mut current_section, Section { name: cell.to_string(), items: Vec::new(), }); if !prev.items.is_empty() { sections.push(prev); } } else { current_section.name = cell.to_string(); } in_quest_area = true; } } // Zone header: non-empty text at col 14-18, no checkbox if in_quest_area && find_checkbox_col(row).is_none() { for col in 14..row.len().min(20) { let cell = get_cell(row, col); if !cell.is_empty() && cell.len() < 50 && !cell.contains("recommandé") && !cell.contains("Rappel") && !cell.contains("Lorsque") && !cell.contains("quêtes sont dans") && !cell.to_lowercase().contains("bash") && !cell.to_lowercase().contains("combat") && !cell.to_lowercase().contains("donjon") && !cell.to_lowercase().contains("horaire") { // Flush pending group if let Some((note, quests)) = pending_group.take() { if !quests.is_empty() { current_section.items.push(SectionItem::Group(GroupItem { note, quests })); } } current_section.items.push(SectionItem::Instruction(InstructionItem { text: format!("__ZONE__:{}", cell), })); break; } } } // Instruction lines (non-quest text within quest area) if in_quest_area && find_checkbox_col(row).is_none() { // Check col 5-9 for group notes or instructions for col in 5..row.len().min(12) { let cell = get_cell(row, col); if !cell.is_empty() && cell.len() > 5 && col < 36 { let lower = cell.to_lowercase(); // Group note pattern if cell.contains("même zone") || cell.contains("ensemble") { if let Some((_, ref mut quests)) = pending_group { // Already in a group - flush and start new let old = pending_group.take().unwrap(); if !old.1.is_empty() { current_section.items.push(SectionItem::Group(GroupItem { note: old.0, quests: old.1 })); } } pending_group = Some((Some(cell.to_string()), Vec::new())); break; } // General instruction if !lower.contains("recommandé") && !lower.contains("rappel") && !lower.contains("bash") && !lower.contains("combat") && !lower.contains("donjon") { current_section.items.push(SectionItem::Instruction(InstructionItem { text: cell.to_string(), })); } break; } } } // Quest rows if let Some((checkbox_col, completed)) = find_checkbox_col(row) { in_quest_area = true; let name_col = checkbox_col + 1; let quest_name = get_cell(row, name_col).to_string(); if quest_name.is_empty() { continue; } let combat_indicators = parse_combat_indicators(row, &combat_legend, checkbox_col); let note = extract_note(row, name_col, &combat_legend); let url = Some(links.get(&quest_name).cloned() .unwrap_or_else(|| slugify(&quest_name))); let quest = QuestItem { name: quest_name, completed, combat_indicators, note, url, }; // Col 7 = grouped (one extra indent vs col 6) if checkbox_col >= 7 { if pending_group.is_none() { pending_group = Some((None, Vec::new())); } if let Some((_, ref mut quests)) = pending_group { quests.push(quest); } } else { // Flush any pending group first if let Some((note, quests)) = pending_group.take() { if !quests.is_empty() { current_section.items.push(SectionItem::Group(GroupItem { note, quests })); } } current_section.items.push(SectionItem::Quest(quest)); } } } // Flush remaining if let Some((note, quests)) = pending_group.take() { if !quests.is_empty() { current_section.items.push(SectionItem::Group(GroupItem { note, quests })); } } if !current_section.items.is_empty() { sections.push(current_section); } GuideData { name: name.to_string(), gid: gid.to_string(), effect, recommended_level, combat_legend, resources, sections, } }