feat: upgrade needed ressources and fight for each quest
This commit is contained in:
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -5701,6 +5701,7 @@ dependencies = [
|
||||
"csv",
|
||||
"dirs-next",
|
||||
"gtk",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"scraper",
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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 §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 {
|
||||
|
||||
@ -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| {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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*(?:À|À)\s*pr(?:é|é)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(¶_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
|
||||
|
||||
Reference in New Issue
Block a user