feat: work on windows resizing

This commit is contained in:
2026-04-23 09:11:01 +02:00
parent 9d181f3676
commit 3fb8e23c07
15 changed files with 1286 additions and 114 deletions

View File

@ -173,6 +173,18 @@ pub fn has_guides(state: State<DbState>) -> Result<bool, String> {
Ok(!guides.is_empty())
}
#[tauri::command]
pub fn get_resource_inventory(state: State<DbState>, profile_id: String) -> Result<Vec<(String, i64)>, 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<DbState>, 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<DbState>, key: String) -> Result<Option<String>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
@ -194,6 +206,294 @@ pub fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> {
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QuestStep {
pub index: usize,
pub text: String,
pub images: Vec<String>,
pub launch_position: Option<String>,
}
#[tauri::command]
pub async fn fetch_quest_detail(url: String) -> Result<Vec<QuestStep>, 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<Vec<QuestStep>, 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<QuestStep> = 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 });
}
continue; // always skip as a regular step
}
}
steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None });
continue;
}
// ── Cases B & C: the child is a wrapper div ───────────────────────
let inner_paras: Vec<_> = child.select(&para_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 });
}
first_para = false;
continue;
}
}
let imgs = if first_para { images.clone() } else { vec![] };
first_para = false;
steps.push(QuestStep { index: steps.len(), text, images: imgs, launch_position: None });
}
}
}
Ok(steps)
}
fn extract_launch_position(para: &scraper::ElementRef, position_sel: &scraper::Selector) -> Option<String> {
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 <span> is a sibling of <strong> (font's grandparent),
// not a sibling of <font> 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 <strong> 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<String> {
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_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<DbState>,
profile_id: String,
quest_name: String,
) -> Result<Vec<i64>, 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<DbState>,
profile_id: String,
quest_name: String,
step_index: i64,
) -> Result<bool, String> {
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 collect_quest_names(data: &parser::GuideData) -> Vec<String> {
let mut names = Vec::new();
for section in &data.sections {