feat: highlight link to other quest or copy item name in quest details

This commit is contained in:
2026-04-25 16:06:06 +02:00
parent a780dd7051
commit 3068b3e352
7 changed files with 186 additions and 44 deletions

View File

@ -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<String>,
pub launch_position: Option<String>,
pub rich_text: Vec<RichSegment>,
}
#[tauri::command]
@ -272,13 +280,14 @@ fn parse_quest_steps(html: &str) -> Result<Vec<QuestStep>, 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<Vec<QuestStep>, 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<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 …"

View File

@ -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<CombatIndicator> {
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<CombatIndicator> {
Vec::new()
}
fn extract_note(row: &[String], name_col: usize, legend: &[CombatType]) -> Option<String> {
// 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<Regex> = 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<usize> = 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
}