543 lines
20 KiB
Rust
543 lines
20 KiB
Rust
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<u32>,
|
|
pub combat_legend: Vec<CombatType>,
|
|
pub resources: Vec<Resource>,
|
|
pub sections: Vec<Section>,
|
|
}
|
|
|
|
#[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<SectionItem>,
|
|
}
|
|
|
|
#[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<CombatIndicator>,
|
|
pub note: Option<String>,
|
|
pub url: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub quests: Vec<QuestItem>,
|
|
}
|
|
|
|
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, String> {
|
|
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<String, String> {
|
|
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<String, String> {
|
|
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<u32> {
|
|
// 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::<u32>() {
|
|
// 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<CombatIndicator> {
|
|
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<String> {
|
|
// 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<usize> = 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<String, String>,
|
|
) -> GuideData {
|
|
let mut reader = csv::ReaderBuilder::new()
|
|
.has_headers(false)
|
|
.flexible(true)
|
|
.from_reader(csv.as_bytes());
|
|
|
|
let rows: Vec<Vec<String>> = 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<u32> = None;
|
|
let mut resources: Vec<Resource> = Vec::new();
|
|
let mut combat_legend: Vec<CombatType> = Vec::new();
|
|
let mut sections: Vec<Section> = 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<String>, Vec<QuestItem>)> = None;
|
|
let mut legend_row_idx: Option<usize> = 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::<Vec<_>>().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,
|
|
}
|
|
}
|