diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1fc71e4..15b9212 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5700,6 +5700,7 @@ dependencies = [ "chrono", "csv", "dirs-next", + "ego-tree", "gtk", "regex", "reqwest 0.12.28", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 29de2fc..b74fb54 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } dirs-next = "2" scraper = "0.20" +ego-tree = "0.6" regex = "1" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6b63232..55bf410 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -208,12 +208,20 @@ pub fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), 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] @@ -272,13 +280,14 @@ fn parse_quest_steps(html: &str) -> Result, String> { 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 }); + steps.push(QuestStep { index: steps.len(), text: String::new(), images, launch_position: pos, rich_text: vec![] }); } continue; // always skip as a regular step } } - steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None }); + let rich_text = element_to_rich_text(&child); + steps.push(QuestStep { index: steps.len(), text, images: vec![], launch_position: None, rich_text }); continue; } @@ -304,16 +313,17 @@ fn parse_quest_steps(html: &str) -> Result, String> { 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 }); + 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 }); + steps.push(QuestStep { index: steps.len(), text, images: imgs, launch_position: None, rich_text }); } } } @@ -410,6 +420,105 @@ fn collect_images_from( 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 …" diff --git a/src-tauri/src/parser.rs b/src-tauri/src/parser.rs index 7299213..91fc1b0 100644 --- a/src-tauri/src/parser.rs +++ b/src-tauri/src/parser.rs @@ -398,42 +398,33 @@ fn is_resource_row(row: &[String]) -> Option<(u32, String)> { None } -fn parse_combat_indicators(row: &[String], legend: &[CombatType], checkbox_col: usize) -> Vec { - let mut indicators = Vec::new(); - for ct in legend { - // Skip the checkbox column and the quest name column — they are not combat indicators - if ct.column <= checkbox_col + 1 { continue; } - let cell = get_cell(row, ct.column); - // Skip empty cells and boolean-looking values - if cell.is_empty() - || cell.eq_ignore_ascii_case("false") - || cell.eq_ignore_ascii_case("true") - { - continue; - } - indicators.push(CombatIndicator { - combat_type: ct.name.clone(), - count: cell.to_string(), - label: None, - evitable: false, - }); - } - indicators +fn parse_combat_indicators(_row: &[String], _legend: &[CombatType], _checkbox_col: usize) -> Vec { + Vec::new() } fn extract_note(row: &[String], name_col: usize, legend: &[CombatType]) -> Option { - // Note is typically in the last significant column after the combat indicators - // Search for non-empty cell after col name_col+1, skipping combat indicator cols + use regex::Regex; + // Matches any combat-indicator cell: optional arrow prefix, optional "x", digits, optional suffix + // Covers: "→x3", "→ x5 (Aléatoire)", "->x1 (évitable)", "3", "x2", etc. + static RE_COMBAT_CELL: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE_COMBAT_CELL.get_or_init(|| { + Regex::new(r"(?i)^(?:→+|-+>)\s*[x×]?\s*\d|^[x×]?\s*\d+$").unwrap() + }); + let combat_cols: std::collections::HashSet = legend.iter().map(|c| c.column).collect(); let max_search = row.len().min(36); for col in (name_col + 1)..max_search { - let cell = get_cell(row, col); - if !cell.is_empty() && !combat_cols.contains(&col) { - // Likely a note - if !cell.eq_ignore_ascii_case("false") && !cell.eq_ignore_ascii_case("true") { - return Some(cell.to_string()); - } + let cell = get_cell(row, col).trim(); + if cell.is_empty() || combat_cols.contains(&col) { + continue; } + if cell.eq_ignore_ascii_case("false") || cell.eq_ignore_ascii_case("true") { + continue; + } + if re.is_match(cell) { + continue; + } + return Some(cell.to_string()); } None } diff --git a/src/components/GuideView.tsx b/src/components/GuideView.tsx index 3b4a3c8..9a9addc 100644 --- a/src/components/GuideView.tsx +++ b/src/components/GuideView.tsx @@ -53,6 +53,7 @@ export default function GuideView() { questUrl={selectedQuest.url} profileId={activeProfileId} onClose={() => setSelectedQuest(null)} + onSelectQuest={setSelectedQuest} /> ); @@ -380,11 +381,6 @@ function QuestRow({ quest, completed, onToggle, onSelect, indent }: { > {quest.name} - {quest.combat_indicators.map((ci, i) => ( - - {combatIcon(ci.combat_type)} x{ci.count} - - ))} {hasPreviewSection && ( diff --git a/src/components/QuestDetailPanel.tsx b/src/components/QuestDetailPanel.tsx index 7f06fd7..f35a0cd 100644 --- a/src/components/QuestDetailPanel.tsx +++ b/src/components/QuestDetailPanel.tsx @@ -1,17 +1,20 @@ import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { QuestStep } from "../types"; +import { QuestStep, RichSegment } from "../types"; import { TextWithCoords } from "./TextWithCoords"; +const DPLN_BASE = "https://www.dofuspourlesnoobs.com"; + interface Props { questName: string; questUrl: string | null; profileId: string; onClose: () => void; + onSelectQuest?: (quest: { name: string; url: string | null }) => void; } -export default function QuestDetailPanel({ questName, questUrl, profileId, onClose }: Props) { +export default function QuestDetailPanel({ questName, questUrl, profileId, onClose, onSelectQuest }: Props) { const [steps, setSteps] = useState([]); const [completedSteps, setCompletedSteps] = useState>(new Set()); const [expandedSteps, setExpandedSteps] = useState>(new Set()); @@ -187,9 +190,6 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo const lines = step.text.split('\n').filter(l => l.trim().length > 0); const needsTruncate = lines.length > 4; - const displayText = needsTruncate && !expanded - ? lines.slice(0, 4).join('\n') - : step.text; return (
toggleStep(step.index)} style={{ @@ -213,7 +213,10 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo fontSize: "12px", color: "#94a3b8", lineHeight: 1.6, whiteSpace: "pre-wrap", wordBreak: "break-word", }}> - + {step.rich_text.length > 0 && (expanded || !needsTruncate) + ? + : + }
{needsTruncate && ( @@ -255,6 +258,42 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo ); } +function RichText({ + segments, + onSelectQuest, +}: { + segments: RichSegment[]; + onSelectQuest?: (quest: { name: string; url: string | null }) => void; +}) { + return ( + <> + {segments.map((seg, i) => { + if (seg.type === "QuestLink") { + return ( + { + e.stopPropagation(); + onSelectQuest?.({ name: seg.text, url: DPLN_BASE + seg.href }); + }} + style={{ + color: "#4a9eff", + textDecoration: "underline", + cursor: "pointer", + }} + onMouseEnter={e => (e.currentTarget.style.color = "#93c5fd")} + onMouseLeave={e => (e.currentTarget.style.color = "#4a9eff")} + > + {seg.text} + + ); + } + return ; + })} + + ); +} + function QuestHeader({ step }: { step: QuestStep }) { return (