use tauri::{AppHandle, Emitter, Manager, State}; use tauri::window::Color; use serde::{Deserialize, Serialize}; use std::sync::Mutex; use std::collections::HashMap; use rusqlite::Connection; use crate::{db, parser}; pub struct DbState(pub Mutex); #[derive(Debug, Serialize, Deserialize)] pub struct GuideListItem { pub gid: String, pub name: String, pub last_synced_at: Option, pub total_quests: usize, pub completed_quests: usize, } #[derive(Debug, Serialize, Deserialize)] pub struct SyncResult { pub synced: usize, pub errors: Vec, } #[tauri::command] pub fn get_profiles(state: State) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; db::get_profiles(&conn).map_err(|e| e.to_string()) } #[tauri::command] pub fn create_profile(state: State, name: String) -> Result { let conn = state.0.lock().map_err(|e| e.to_string())?; db::create_profile(&conn, &name).map_err(|e| e.to_string()) } #[tauri::command] pub fn delete_profile(state: State, profile_id: String) -> Result<(), String> { let conn = state.0.lock().map_err(|e| e.to_string())?; db::delete_profile(&conn, &profile_id).map_err(|e| e.to_string()) } #[tauri::command] pub fn get_completed_quests(state: State, profile_id: String) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; db::get_completed_quests(&conn, &profile_id).map_err(|e| e.to_string()) } #[tauri::command] pub fn toggle_quest(state: State, profile_id: String, quest_name: String) -> Result { let conn = state.0.lock().map_err(|e| e.to_string())?; db::toggle_quest(&conn, &profile_id, &quest_name).map_err(|e| e.to_string()) } #[tauri::command] pub fn get_guides_list(state: State, profile_id: String) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; let guides = db::get_guides(&conn).map_err(|e| e.to_string())?; let completed = db::get_completed_quests(&conn, &profile_id).map_err(|e| e.to_string())?; let completed_set: std::collections::HashSet = completed.into_iter().collect(); let items = guides.into_iter().map(|g| { let data: parser::GuideData = serde_json::from_str(&g.data).unwrap_or_else(|_| parser::GuideData { name: g.name.clone(), gid: g.gid.clone(), effect: String::new(), recommended_level: None, combat_legend: vec![], resources: vec![], sections: vec![], }); let all_quests = collect_quest_names(&data); let total = all_quests.len(); let completed_count = all_quests.iter().filter(|q| completed_set.contains(*q)).count(); GuideListItem { gid: g.gid, name: g.name, last_synced_at: g.last_synced_at, total_quests: total, completed_quests: completed_count, } }).collect(); Ok(items) } #[tauri::command] pub fn get_guide(state: State, gid: String) -> Result { let conn = state.0.lock().map_err(|e| e.to_string())?; let guides = db::get_guides(&conn).map_err(|e| e.to_string())?; if let Some(g) = guides.into_iter().find(|g| g.gid == gid) { serde_json::from_str(&g.data).map_err(|e| e.to_string()) } else { Err(format!("Guide {} not found", gid)) } } #[tauri::command] pub async fn sync_guides(state: State<'_, DbState>) -> Result { let mut synced = 0; let mut errors = Vec::new(); for (gid, name) in parser::TABS { let gid = gid.to_string(); let name = name.to_string(); // Run blocking HTTP+parse in a dedicated thread to avoid runtime conflict let result = tokio::task::spawn_blocking({ let gid = gid.clone(); let name = name.clone(); move || -> Result { let csv = parser::fetch_csv(&gid)?; let links = parser::fetch_quest_links(&gid); let data = parser::parse_guide_with_links(&gid, &name, &csv, &links); serde_json::to_string(&data).map_err(|e| e.to_string()) } }).await.map_err(|e| e.to_string())?; match result { Ok(json) => { let conn = state.0.lock().map_err(|e| e.to_string())?; db::upsert_guide(&conn, &gid, &name, &json).map_err(|e| e.to_string())?; synced += 1; } Err(e) => { errors.push(format!("{}: {}", name, e)); } } } Ok(SyncResult { synced, errors }) } #[derive(Debug, Serialize, Deserialize)] pub struct TabInfo { pub gid: String, pub name: String, } #[tauri::command] pub fn get_tabs_list() -> Vec { parser::TABS.iter().map(|(gid, name)| TabInfo { gid: gid.to_string(), name: name.to_string(), }).collect() } #[tauri::command] pub async fn sync_single_guide(state: State<'_, DbState>, gid: String, name: String) -> Result<(), String> { let json = tokio::task::spawn_blocking({ let gid = gid.clone(); let name = name.clone(); move || -> Result { let csv = parser::fetch_csv(&gid)?; let links = parser::fetch_quest_links(&gid); let data = parser::parse_guide_with_links(&gid, &name, &csv, &links); serde_json::to_string(&data).map_err(|e| e.to_string()) } }).await.map_err(|e| e.to_string())??; let conn = state.0.lock().map_err(|e| e.to_string())?; db::upsert_guide(&conn, &gid, &name, &json).map_err(|e| e.to_string())?; Ok(()) } // This remains for backwards compat but delegates to per-guide approach #[tauri::command] pub fn has_guides(state: State) -> Result { let conn = state.0.lock().map_err(|e| e.to_string())?; let guides = db::get_guides(&conn).map_err(|e| e.to_string())?; Ok(!guides.is_empty()) } #[tauri::command] pub fn get_resource_inventory(state: State, profile_id: String) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; db::get_resource_inventory(&conn, &profile_id).map_err(|e| e.to_string()) } #[tauri::command] pub fn set_resource_quantity(state: State, profile_id: String, resource_name: String, quantity: i64) -> Result<(), String> { let conn = state.0.lock().map_err(|e| e.to_string())?; db::set_resource_quantity(&conn, &profile_id, &resource_name, quantity).map_err(|e| e.to_string()) } #[tauri::command] pub fn get_setting(state: State, key: String) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; Ok(db::get_setting(&conn, &key)) } #[tauri::command] pub fn set_setting(state: State, key: String, value: String) -> Result<(), String> { let conn = state.0.lock().map_err(|e| e.to_string())?; db::set_setting(&conn, &key, &value).map_err(|e| e.to_string()) } #[tauri::command] pub fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> { if let Some(win) = app.get_webview_window("main") { win.set_always_on_top(value).map_err(|e| e.to_string()) } else { Err("Window not found".to_string()) } } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type")] pub enum RichSegment { Text { text: String }, QuestLink { text: String, href: String }, } #[derive(Debug, Serialize, Deserialize)] pub struct QuestStep { pub index: usize, pub text: String, pub images: Vec, pub launch_position: Option, pub rich_text: Vec, } #[tauri::command] pub async fn fetch_quest_detail(url: String) -> Result, String> { tokio::task::spawn_blocking(move || { let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(20)) .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .build() .map_err(|e| e.to_string())?; let html = client.get(&url) .send() .map_err(|e| format!("Erreur réseau : {}", e))? .text() .map_err(|e| e.to_string())?; parse_quest_steps(&html) }).await.map_err(|e| e.to_string())? } fn parse_quest_steps(html: &str) -> Result, String> { use scraper::{Html, Selector}; const BASE_URL: &str = "https://www.dofuspourlesnoobs.com"; let document = Html::parse_document(html); let children_sel = Selector::parse("#wsite-content > div").unwrap(); let para_sel = Selector::parse("div.paragraph, div.paragraphe").unwrap(); let img_link_sel = Selector::parse("div.wsite-image a, div.wsite-image img").unwrap(); // Blue = Position de lancement label; red = other info labels (Prérequis, Niveau…) let position_sel = Selector::parse("font[color='#3a96b8'], font[color='#3A96B8']").unwrap(); let info_block_sel = Selector::parse( "font[color='#3a96b8'], font[color='#3A96B8'], font[color='#ff0000'], font[color='#FF0000']" ).unwrap(); let mut steps: Vec = Vec::new(); let mut first = true; let mut header_done = false; for child in document.select(&children_sel) { if first { first = false; continue; } let v = child.value(); // ── Case A: the child IS itself a paragraph ────────────────────────── if v.classes().any(|c| c == "paragraph" || c == "paragraphe") { let text = element_to_text(&child).trim().to_string(); if text.is_empty() || is_date_meta(&text) { continue; } if !header_done { header_done = true; // Info block detected by colored font labels typical of DPLN info sections if child.select(&info_block_sel).next().is_some() { let pos = extract_launch_position(&child, &position_sel); if pos.is_some() { let images = collect_images_from(&child, &img_link_sel, BASE_URL); steps.push(QuestStep { index: steps.len(), text: String::new(), images, launch_position: pos, rich_text: vec![] }); } continue; // always skip as a regular step } } let rich_text = element_to_rich_text(&child); steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None, rich_text }); continue; } // ── Cases B & C: the child is a wrapper div ─────────────────────── let inner_paras: Vec<_> = child.select(¶_sel).collect(); let images = collect_images_from(&child, &img_link_sel, BASE_URL); if inner_paras.is_empty() { if !images.is_empty() { if let Some(last) = steps.last_mut() { last.images.extend(images); } } } else { let mut first_para = true; for para in &inner_paras { let text = element_to_text(para).trim().to_string(); if text.is_empty() || is_date_meta(&text) { continue; } if !header_done && first_para { header_done = true; if para.select(&info_block_sel).next().is_some() { let pos = extract_launch_position(para, &position_sel); if pos.is_some() { let imgs = images.clone(); steps.push(QuestStep { index: steps.len(), text: String::new(), images: imgs, launch_position: pos, rich_text: vec![] }); } first_para = false; continue; } } let rich_text = element_to_rich_text(para); let imgs = if first_para { images.clone() } else { vec![] }; first_para = false; steps.push(QuestStep { index: steps.len(), text, images: imgs, launch_position: None, rich_text }); } } } Ok(steps) } fn extract_launch_position(para: &scraper::ElementRef, position_sel: &scraper::Selector) -> Option { for font_el in para.select(position_sel) { let label: String = font_el.text().collect(); if !label.to_lowercase().contains("position") { continue; } let tree = font_el.tree(); let para_id = para.id(); let mut current_id = font_el.id(); // Walk up from font_el toward para, collecting text from siblings after each ancestor. // Necessary because the value is a sibling of (font's grandparent), // not a sibling of itself. loop { let current_node = tree.get(current_id)?; let parent_node = current_node.parent()?; let parent_id = parent_node.id(); let mut after_current = false; let mut result = String::new(); for sibling in parent_node.children() { if sibling.id() == current_id { after_current = true; continue; } if !after_current { continue; } // A new means a new field — stop collecting if let scraper::Node::Element(e) = sibling.value() { if e.name() == "strong" { break; } } if let Some(elem) = scraper::ElementRef::wrap(sibling) { let t: String = elem.text().collect(); let t = t.trim().to_string(); if !t.is_empty() { if !result.is_empty() { result.push(' '); } result.push_str(&t); } } else if let scraper::Node::Text(t) = sibling.value() { let s = t.text.trim(); if !s.is_empty() { if !result.is_empty() { result.push(' '); } result.push_str(s); } } } let pos = result.trim().trim_end_matches('.').trim().to_string(); if !pos.is_empty() { return Some(pos); } if parent_id == para_id { break; } current_id = parent_id; } } None } fn collect_images_from( el: &scraper::ElementRef, img_link_sel: &scraper::Selector, base_url: &str, ) -> Vec { let mut images = Vec::new(); let mut seen = std::collections::HashSet::new(); for img_el in el.select(img_link_sel) { let url_opt = img_el.value().attr("href") .filter(|u| is_image_url(u)) .or_else(|| img_el.value().attr("src").filter(|u| is_image_url(u))); if let Some(url) = url_opt { let absolute = if url.starts_with('/') { format!("{}{}", base_url, url) } else { url.to_string() }; if seen.insert(absolute.clone()) { images.push(absolute); } } } images } fn is_quest_link(href: &str) -> bool { href.starts_with('/') && href.ends_with(".html") && !href.contains("/uploads/") && !href.contains('#') } fn collect_rich_impl( node: ego_tree::NodeRef, segments: &mut Vec, buf: &mut String, ) { for child in node.children() { match child.value() { scraper::Node::Text(t) => { let s = t.text.trim(); if !s.is_empty() { if !buf.is_empty() && !buf.ends_with(|c: char| c.is_whitespace()) { buf.push(' '); } buf.push_str(s); } } scraper::Node::Element(e) => { let tag = e.name(); if matches!(tag, "script" | "style" | "noscript") { // skip subtree } else if tag == "a" { if let Some(href) = e.attr("href") { if is_quest_link(href) { let t = std::mem::take(buf); let t = t.trim().to_string(); if !t.is_empty() { segments.push(RichSegment::Text { text: t }); } let link_text: String = child .descendants() .filter_map(|n| { if let scraper::Node::Text(txt) = n.value() { let s = txt.text.trim(); if s.is_empty() { None } else { Some(s.to_string()) } } else { None } }) .collect::>() .join(" "); if !link_text.is_empty() { segments.push(RichSegment::QuestLink { text: link_text, href: href.to_string(), }); } // Don't recurse into the } else { collect_rich_impl(child, segments, buf); } } else { collect_rich_impl(child, segments, buf); } } else { if matches!(tag, "p" | "br" | "li" | "div" | "tr" | "h1" | "h2" | "h3" | "h4") { if !buf.is_empty() && !buf.ends_with('\n') { buf.push('\n'); } } collect_rich_impl(child, segments, buf); } } _ => {} } } } fn element_to_rich_text(el: &scraper::ElementRef) -> Vec { let mut segments: Vec = Vec::new(); let mut buf = String::new(); collect_rich_impl(**el, &mut segments, &mut buf); // Flush remaining text let mut result = String::new(); let mut prev_empty = false; for line in buf.lines() { let t = line.trim(); if t.is_empty() { if !prev_empty { result.push('\n'); } prev_empty = true; } else { result.push_str(t); result.push('\n'); prev_empty = false; } } let t = result.trim().to_string(); if !t.is_empty() { segments.push(RichSegment::Text { text: t }); } segments } fn is_date_meta(text: &str) -> bool { let lower = text.to_lowercase(); // Matches Weebly blog metadata lines like "Publié le 01/01/2024" or "Mis à jour le …" let is_meta_word = lower.starts_with("publi") || lower.starts_with("mis à jour") || lower.starts_with("mis en ligne") || lower.starts_with("modifi") || lower.starts_with("rédigé") || lower.starts_with("redige"); // Also catch bare date lines like "01/01/2024" or "2024-01-01" let digit_count = text.chars().filter(|c| c.is_ascii_digit()).count(); let sep_count = text.chars().filter(|&c| c == '/' || c == '-').count(); let is_bare_date = text.len() < 40 && digit_count >= 6 && sep_count >= 2; is_meta_word || is_bare_date } fn is_image_url(url: &str) -> bool { url.contains("uploads") || url.ends_with(".jpg") || url.ends_with(".jpeg") || url.ends_with(".png") || url.ends_with(".webp") } fn element_to_text(el: &scraper::ElementRef) -> String { let mut out = String::new(); for node in el.descendants() { match node.value() { scraper::Node::Text(t) => { let s = t.text.trim(); if !s.is_empty() { if !out.is_empty() && !out.ends_with('\n') && !out.ends_with(' ') { out.push(' '); } out.push_str(s); } } scraper::Node::Element(e) => { if matches!(e.name(), "script" | "style" | "noscript") { continue; } if matches!(e.name(), "p" | "br" | "h1" | "h2" | "h3" | "h4" | "li" | "div" | "tr") { if !out.is_empty() && !out.ends_with('\n') { out.push('\n'); } } } _ => {} } } // Collapse multiple blank lines let mut result = String::new(); let mut prev_empty = false; for line in out.lines() { let t = line.trim(); if t.is_empty() { if !prev_empty { result.push('\n'); } prev_empty = true; } else { result.push_str(t); result.push('\n'); prev_empty = false; } } result.trim().to_string() } #[tauri::command] pub fn get_completed_steps( state: State, profile_id: String, quest_name: String, ) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; db::get_completed_steps(&conn, &profile_id, &quest_name).map_err(|e| e.to_string()) } #[tauri::command] pub fn toggle_quest_step( state: State, profile_id: String, quest_name: String, step_index: i64, ) -> Result { let conn = state.0.lock().map_err(|e| e.to_string())?; db::toggle_quest_step(&conn, &profile_id, &quest_name, step_index).map_err(|e| e.to_string()) } fn percent_encode(s: &str) -> String { let mut result = String::new(); for byte in s.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { result.push(byte as char); } b => result.push_str(&format!("%{:02X}", b)), } } result } #[tauri::command] pub async fn open_image_viewer( app: AppHandle, state: State<'_, DbState>, image_url: String, ) -> Result<(), String> { if let Some(win) = app.get_webview_window("image-viewer") { win.emit("set-viewer-image", &image_url).map_err(|e| e.to_string())?; win.set_focus().map_err(|e| e.to_string())?; return Ok(()); } let (w, h, x, y) = { let conn = state.0.lock().map_err(|e| e.to_string())?; let w: f64 = db::get_setting(&conn, "viewer_width").and_then(|v| v.parse().ok()).unwrap_or(600.0); let h: f64 = db::get_setting(&conn, "viewer_height").and_then(|v| v.parse().ok()).unwrap_or(500.0); let x: Option = db::get_setting(&conn, "viewer_x").and_then(|v| v.parse().ok()); let y: Option = db::get_setting(&conn, "viewer_y").and_then(|v| v.parse().ok()); (w, h, x, y) }; let path = format!("/?viewer=1&imageUrl={}", percent_encode(&image_url)); let mut builder = tauri::WebviewWindowBuilder::new( &app, "image-viewer", tauri::WebviewUrl::App(path.into()), ) .title("Image") .decorations(false) .resizable(true) .always_on_top(true) .background_color(Color(13, 17, 23, 255)) .inner_size(w, h); if let (Some(x), Some(y)) = (x, y) { builder = builder.position(x, y); } let viewer = builder.build().map_err(|e| e.to_string())?; viewer.eval(r#"(function(){ var s=document.createElement('style'); s.textContent='::-webkit-scrollbar{display:none!important;width:0!important;height:0!important}*{scrollbar-width:none!important}'; var apply=function(){if(document.head)document.head.appendChild(s)}; if(document.head)apply();else document.addEventListener('DOMContentLoaded',apply); })();"#).ok(); Ok(()) } /// Lit le cache SQLite pour une liste d'URLs et retourne les indicateurs déjà stockés. /// Synchrone et instantané — utilisé au chargement de la vue pour afficher les données en cache. #[tauri::command] pub fn get_cached_previews( state: State, quest_urls: Vec, ) -> Result>, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; Ok(db::get_cached_previews(&conn, &quest_urls)) } /// Scrape toutes les quêtes d'un guide qui ne sont pas encore en cache, stocke les résultats /// en DB et retourne l'ensemble `url → indicateurs` pour le guide demandé. #[tauri::command] pub async fn fetch_guide_previews( state: State<'_, DbState>, gid: String, ) -> Result>, String> { // 1. Charge le guide depuis la DB (section critique minimale) let guide = { let conn = state.0.lock().map_err(|e| e.to_string())?; db::get_guide(&conn, &gid) .map_err(|e| e.to_string())? .ok_or_else(|| format!("Guide {} introuvable", gid))? }; // 2. Collecte toutes les URLs des quêtes du guide let all_urls: Vec = collect_quest_urls(&guide); // 3. Détermine quelles URLs ne sont pas encore en cache let cached_urls = { let conn = state.0.lock().map_err(|e| e.to_string())?; db::get_cached_urls(&conn, &all_urls) }; let urls_to_fetch: Vec = all_urls .iter() .filter(|u| !cached_urls.contains(*u)) .cloned() .collect(); // 4. Scrape les pages manquantes (bloquant → spawn_blocking) for url in urls_to_fetch { let url_clone = url.clone(); let result = tokio::task::spawn_blocking(move || -> Result, String> { let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(20)) .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .build() .map_err(|e| e.to_string())?; let html = client .get(&url_clone) .send() .map_err(|e| format!("Erreur réseau {} : {}", url_clone, e))? .text() .map_err(|e| e.to_string())?; Ok(parser::extract_a_prevoir(&html)) }) .await .map_err(|e| e.to_string())?; // On persiste même si le résultat est vide (évite de re-scraper une page sans section) match result { Ok(indicators) => { let conn = state.0.lock().map_err(|e| e.to_string())?; db::upsert_preview(&conn, &url, &indicators).map_err(|e| e.to_string())?; } Err(e) => { // Erreur réseau non fatale : on log et on continue eprintln!("[fetch_guide_previews] Erreur pour {} : {}", url, e); } } } // 5. Retourne l'ensemble du cache pour toutes les URLs du guide let conn = state.0.lock().map_err(|e| e.to_string())?; Ok(db::get_cached_previews(&conn, &all_urls)) } /// Extrait toutes les URLs de quêtes depuis un `GuideData` (Quest + Group.quests). fn collect_quest_urls(data: &parser::GuideData) -> Vec { let mut urls = Vec::new(); for section in &data.sections { for item in §ion.items { match item { parser::SectionItem::Quest(q) => { if let Some(url) = &q.url { urls.push(url.clone()); } } parser::SectionItem::Group(g) => { for q in &g.quests { if let Some(url) = &q.url { urls.push(url.clone()); } } } parser::SectionItem::Instruction(_) => {} } } } urls } fn collect_quest_names(data: &parser::GuideData) -> Vec { let mut names = Vec::new(); for section in &data.sections { for item in §ion.items { match item { parser::SectionItem::Quest(q) => names.push(q.name.clone()), parser::SectionItem::Group(g) => { for q in &g.quests { names.push(q.name.clone()); } } _ => {} } } } names }