feat: upgrade needed ressources and fight for each quest

This commit is contained in:
2026-04-25 14:39:16 +02:00
parent de6550cee4
commit 0e577b8efd
13 changed files with 560 additions and 2 deletions

1
src-tauri/Cargo.lock generated
View File

@ -5701,6 +5701,7 @@ dependencies = [
"csv",
"dirs-next",
"gtk",
"regex",
"reqwest 0.12.28",
"rusqlite",
"scraper",

View File

@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
dirs-next = "2"
scraper = "0.20"
regex = "1"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = { version = "2.0", features = ["v2_38"] }

View File

@ -2,6 +2,7 @@ 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};
@ -556,6 +557,112 @@ pub async fn open_image_viewer(
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 &section.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 {

View File

@ -2,6 +2,9 @@ use rusqlite::{Connection, Result, params};
use serde::{Deserialize, Serialize};
use chrono::Utc;
use uuid::Uuid;
use std::collections::HashMap;
use crate::parser::CombatIndicator;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Profile {
@ -76,10 +79,125 @@ pub fn migrate(conn: &Connection) -> Result<()> {
PRIMARY KEY (profile_id, resource_name),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS quest_previews (
quest_url TEXT PRIMARY KEY,
indicators_json TEXT NOT NULL,
cached_at TEXT NOT NULL DEFAULT (datetime('now'))
);
")?;
Ok(())
}
pub fn get_cached_previews(conn: &Connection, urls: &[String]) -> HashMap<String, Vec<CombatIndicator>> {
if urls.is_empty() {
return HashMap::new();
}
// Construit les placeholders : (?1, ?2, …)
let placeholders: String = (1..=urls.len())
.map(|i| format!("?{}", i))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT quest_url, indicators_json FROM quest_previews WHERE quest_url IN ({})",
placeholders
);
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return HashMap::new(),
};
// rusqlite attend &dyn ToSql — on construit un vecteur de références
let params_vec: Vec<&dyn rusqlite::types::ToSql> = urls
.iter()
.map(|u| u as &dyn rusqlite::types::ToSql)
.collect();
let rows = match stmt.query_map(params_vec.as_slice(), |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
}) {
Ok(r) => r,
Err(_) => return HashMap::new(),
};
let mut map = HashMap::new();
for row in rows.flatten() {
let (url, json) = row;
if let Ok(indicators) = serde_json::from_str::<Vec<CombatIndicator>>(&json) {
map.insert(url, indicators);
}
}
map
}
pub fn upsert_preview(conn: &Connection, url: &str, indicators: &[CombatIndicator]) -> Result<()> {
let json = serde_json::to_string(indicators).unwrap_or_else(|_| "[]".to_string());
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO quest_previews (quest_url, indicators_json, cached_at) VALUES (?1, ?2, ?3)
ON CONFLICT(quest_url) DO UPDATE SET indicators_json=excluded.indicators_json, cached_at=excluded.cached_at",
params![url, json, now],
)?;
Ok(())
}
/// Retourne l'ensemble des URLs déjà présentes dans quest_previews parmi celles fournies.
pub fn get_cached_urls(conn: &Connection, urls: &[String]) -> std::collections::HashSet<String> {
if urls.is_empty() {
return std::collections::HashSet::new();
}
let placeholders: String = (1..=urls.len())
.map(|i| format!("?{}", i))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT quest_url FROM quest_previews WHERE quest_url IN ({})",
placeholders
);
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return std::collections::HashSet::new(),
};
let params_vec: Vec<&dyn rusqlite::types::ToSql> = urls
.iter()
.map(|u| u as &dyn rusqlite::types::ToSql)
.collect();
let rows = match stmt.query_map(params_vec.as_slice(), |row| row.get::<_, String>(0)) {
Ok(r) => r,
Err(_) => return std::collections::HashSet::new(),
};
rows.flatten().collect()
}
/// Charge un guide depuis la DB à partir de son gid.
pub fn get_guide(conn: &Connection, gid: &str) -> Result<Option<crate::parser::GuideData>> {
let result = conn.query_row(
"SELECT data FROM guides WHERE gid = ?1",
params![gid],
|row| row.get::<_, String>(0),
);
match result {
Ok(json) => {
let data = serde_json::from_str(&json)
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(e),
))?;
Ok(Some(data))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
pub fn get_profiles(conn: &Connection) -> Result<Vec<Profile>> {
let mut stmt = conn.prepare("SELECT id, name, created_at FROM profiles ORDER BY created_at ASC")?;
let rows = stmt.query_map([], |row| {

View File

@ -60,6 +60,8 @@ pub fn run() {
commands::get_resource_inventory,
commands::set_resource_quantity,
commands::open_image_viewer,
commands::get_cached_previews,
commands::fetch_guide_previews,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -50,6 +50,136 @@ pub struct QuestItem {
pub struct CombatIndicator {
pub combat_type: String,
pub count: String,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub evitable: bool,
}
/// Extrait les indicateurs de combat depuis la section "À prévoir" d'une page de quête DPLN.
///
/// La section est identifiée par un `<strong>` contenant "À prévoir" dans un `<div class="paragraph">`.
/// Le fragment HTML entre ce marqueur et le prochain `<strong>` est re-parsé pour extraire les `<li>`.
pub fn extract_a_prevoir(html: &str) -> Vec<CombatIndicator> {
use scraper::{Html, Selector};
use regex::Regex;
// Sélecteur sur tous les paragraphes potentiels du header de quête
let para_sel = Selector::parse("div.paragraph").unwrap();
let document = Html::parse_document(html);
// Regex pour détecter "À prévoir" dans le inner_html (gère les entités HTML)
let re_header = Regex::new(
r"(?i)<strong[^>]*>\s*(?:&Agrave;|À)\s*pr(?:&eacute;|é)voir\s*:?\s*</strong>"
).unwrap();
// Regex pour extraire le count en début de texte : "2 x ..." ou "Des ..."
let re_count = Regex::new(r"(?i)^(\d+)\s*[xX]\s*").unwrap();
for para in document.select(&para_sel) {
let inner = para.inner_html();
if !re_header.is_match(&inner) {
continue;
}
// Coupe le fragment après le marqueur "À prévoir" jusqu'au prochain <strong> (champ suivant)
let after_marker = re_header.splitn(&inner, 2).nth(1).unwrap_or("");
// Tronque au prochain <strong> pour ne pas déborder sur un autre champ
let fragment_html = match after_marker.find("<strong") {
Some(pos) => &after_marker[..pos],
None => after_marker,
};
let fragment = Html::parse_fragment(fragment_html);
let li_sel = Selector::parse("li").unwrap();
let mut indicators = Vec::new();
for li in fragment.select(&li_sel) {
let raw_text: String = li.text().collect::<Vec<_>>().join(" ");
let text = raw_text.trim().to_string();
if text.is_empty() {
continue;
}
let lower = text.to_lowercase();
let evitable = lower.contains("évitable") || lower.contains("evitable");
// Extraction du count
let count = if lower.starts_with("des ") {
"?".to_string()
} else if let Some(cap) = re_count.captures(&text) {
cap[1].to_string()
} else {
"1".to_string()
};
// Texte sans le préfixe "N x "
let text_without_count = re_count.replace(&text, "").to_string();
let lower_no_count = text_without_count.to_lowercase();
// Mapping vers combat_type
let (combat_type, label) = if lower_no_count.contains("donjon") {
// Extrait le nom du donjon : tout sauf le mot "donjon" et ce qui suit
let label = extract_donjon_label(&text_without_count);
("donjon".to_string(), Some(label))
} else if lower_no_count.contains("combat") {
if lower_no_count.contains("tactique") {
("combat_tactique".to_string(), None)
} else if lower_no_count.contains("vague") {
("combat_vagues".to_string(), None)
} else if lower_no_count.contains("aléatoire") || lower_no_count.contains("aleatoire") {
("combat_aleatoire".to_string(), None)
} else if lower_no_count.contains("monstre") {
("combat_zone".to_string(), None)
} else if lower_no_count.contains("seul") {
("solo".to_string(), None)
} else if lower_no_count.contains("groupe") || lower_no_count.contains("réalisable") || lower_no_count.contains("realisable") {
("groupe".to_string(), None)
} else {
// Combat sans précision supplémentaire
("groupe".to_string(), None)
}
} else if lower_no_count.contains("aller à") || lower_no_count.contains("aller a") {
("deplacement".to_string(), None)
} else {
// Fallback : item nommé
let label = text_without_count
.trim()
.trim_end_matches('.')
.trim()
.to_string();
("item".to_string(), Some(label))
};
indicators.push(CombatIndicator {
combat_type,
count,
label,
evitable,
});
}
// Premier paragraphe "À prévoir" trouvé — on retourne immédiatement
return indicators;
}
Vec::new()
}
/// Extrait le nom du donjon depuis un texte de type "Donjon Antre du Dragon Cochon."
/// Supprime le mot "Donjon" en début (case-insensitive), nettoie la ponctuation finale.
fn extract_donjon_label(text: &str) -> String {
let re = regex::Regex::new(r"(?i)^donjon\s*").unwrap();
let without = re.replace(text, "");
// Retire les suffixes parenthétiques courants : "(réalisable en groupe)."
let trimmed = if let Some(paren) = without.find('(') {
without[..paren].trim().to_string()
} else {
without.trim().trim_end_matches('.').trim().to_string()
};
trimmed
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@ -284,6 +414,8 @@ fn parse_combat_indicators(row: &[String], legend: &[CombatType], checkbox_col:
indicators.push(CombatIndicator {
combat_type: ct.name.clone(),
count: cell.to_string(),
label: None,
evitable: false,
});
}
indicators