790 lines
28 KiB
Rust
790 lines
28 KiB
Rust
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<Connection>);
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct GuideListItem {
|
|
pub gid: String,
|
|
pub name: String,
|
|
pub last_synced_at: Option<String>,
|
|
pub total_quests: usize,
|
|
pub completed_quests: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct SyncResult {
|
|
pub synced: usize,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_profiles(state: State<DbState>) -> Result<Vec<db::Profile>, 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<DbState>, name: String) -> Result<db::Profile, String> {
|
|
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<DbState>, 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<DbState>, profile_id: String) -> Result<Vec<String>, 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<DbState>, profile_id: String, quest_name: String) -> Result<bool, String> {
|
|
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<DbState>, profile_id: String) -> Result<Vec<GuideListItem>, 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<String> = 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<DbState>, gid: String) -> Result<parser::GuideData, String> {
|
|
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<SyncResult, String> {
|
|
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<String, String> {
|
|
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<TabInfo> {
|
|
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<String, String> {
|
|
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<DbState>) -> Result<bool, String> {
|
|
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<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())?;
|
|
Ok(db::get_setting(&conn, &key))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_setting(state: State<DbState>, 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<String>,
|
|
pub launch_position: Option<String>,
|
|
pub rich_text: Vec<RichSegment>,
|
|
}
|
|
|
|
#[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, 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<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_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<scraper::Node>,
|
|
segments: &mut Vec<RichSegment>,
|
|
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::<Vec<_>>()
|
|
.join(" ");
|
|
if !link_text.is_empty() {
|
|
segments.push(RichSegment::QuestLink {
|
|
text: link_text,
|
|
href: href.to_string(),
|
|
});
|
|
}
|
|
// Don't recurse into the <a>
|
|
} 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<RichSegment> {
|
|
let mut segments: Vec<RichSegment> = 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<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 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<f64> = db::get_setting(&conn, "viewer_x").and_then(|v| v.parse().ok());
|
|
let y: Option<f64> = 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<DbState>,
|
|
quest_urls: Vec<String>,
|
|
) -> Result<HashMap<String, Vec<parser::CombatIndicator>>, 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<HashMap<String, Vec<parser::CombatIndicator>>, 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<String> = 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<String> = 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<Vec<parser::CombatIndicator>, 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<String> {
|
|
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<String> {
|
|
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
|
|
}
|