feat: highlight link to other quest or copy item name in quest details
This commit is contained in:
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -5700,6 +5700,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"csv",
|
"csv",
|
||||||
"dirs-next",
|
"dirs-next",
|
||||||
|
"ego-tree",
|
||||||
"gtk",
|
"gtk",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
|||||||
@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
dirs-next = "2"
|
dirs-next = "2"
|
||||||
scraper = "0.20"
|
scraper = "0.20"
|
||||||
|
ego-tree = "0.6"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
|||||||
@ -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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct QuestStep {
|
pub struct QuestStep {
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub images: Vec<String>,
|
pub images: Vec<String>,
|
||||||
pub launch_position: Option<String>,
|
pub launch_position: Option<String>,
|
||||||
|
pub rich_text: Vec<RichSegment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@ -272,13 +280,14 @@ fn parse_quest_steps(html: &str) -> Result<Vec<QuestStep>, String> {
|
|||||||
let pos = extract_launch_position(&child, &position_sel);
|
let pos = extract_launch_position(&child, &position_sel);
|
||||||
if pos.is_some() {
|
if pos.is_some() {
|
||||||
let images = collect_images_from(&child, &img_link_sel, BASE_URL);
|
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
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,16 +313,17 @@ fn parse_quest_steps(html: &str) -> Result<Vec<QuestStep>, String> {
|
|||||||
let pos = extract_launch_position(para, &position_sel);
|
let pos = extract_launch_position(para, &position_sel);
|
||||||
if pos.is_some() {
|
if pos.is_some() {
|
||||||
let imgs = images.clone();
|
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;
|
first_para = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rich_text = element_to_rich_text(para);
|
||||||
let imgs = if first_para { images.clone() } else { vec![] };
|
let imgs = if first_para { images.clone() } else { vec![] };
|
||||||
first_para = false;
|
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
|
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 {
|
fn is_date_meta(text: &str) -> bool {
|
||||||
let lower = text.to_lowercase();
|
let lower = text.to_lowercase();
|
||||||
// Matches Weebly blog metadata lines like "Publié le 01/01/2024" or "Mis à jour le …"
|
// Matches Weebly blog metadata lines like "Publié le 01/01/2024" or "Mis à jour le …"
|
||||||
|
|||||||
@ -398,42 +398,33 @@ fn is_resource_row(row: &[String]) -> Option<(u32, String)> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_combat_indicators(row: &[String], legend: &[CombatType], checkbox_col: usize) -> Vec<CombatIndicator> {
|
fn parse_combat_indicators(_row: &[String], _legend: &[CombatType], _checkbox_col: usize) -> Vec<CombatIndicator> {
|
||||||
let mut indicators = Vec::new();
|
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 extract_note(row: &[String], name_col: usize, legend: &[CombatType]) -> Option<String> {
|
fn extract_note(row: &[String], name_col: usize, legend: &[CombatType]) -> Option<String> {
|
||||||
// Note is typically in the last significant column after the combat indicators
|
use regex::Regex;
|
||||||
// Search for non-empty cell after col name_col+1, skipping combat indicator cols
|
// 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 combat_cols: std::collections::HashSet<usize> = legend.iter().map(|c| c.column).collect();
|
||||||
let max_search = row.len().min(36);
|
let max_search = row.len().min(36);
|
||||||
for col in (name_col + 1)..max_search {
|
for col in (name_col + 1)..max_search {
|
||||||
let cell = get_cell(row, col);
|
let cell = get_cell(row, col).trim();
|
||||||
if !cell.is_empty() && !combat_cols.contains(&col) {
|
if cell.is_empty() || combat_cols.contains(&col) {
|
||||||
// Likely a note
|
continue;
|
||||||
if !cell.eq_ignore_ascii_case("false") && !cell.eq_ignore_ascii_case("true") {
|
|
||||||
return Some(cell.to_string());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,7 @@ export default function GuideView() {
|
|||||||
questUrl={selectedQuest.url}
|
questUrl={selectedQuest.url}
|
||||||
profileId={activeProfileId}
|
profileId={activeProfileId}
|
||||||
onClose={() => setSelectedQuest(null)}
|
onClose={() => setSelectedQuest(null)}
|
||||||
|
onSelectQuest={setSelectedQuest}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -380,11 +381,6 @@ function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
|
|||||||
>
|
>
|
||||||
{quest.name}
|
{quest.name}
|
||||||
</span>
|
</span>
|
||||||
{quest.combat_indicators.map((ci, i) => (
|
|
||||||
<span key={i} style={{ fontSize: "11px", color: "#94a3b8", whiteSpace: "nowrap", flexShrink: 0 }}>
|
|
||||||
{combatIcon(ci.combat_type)} x{ci.count}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasPreviewSection && (
|
{hasPreviewSection && (
|
||||||
|
|||||||
@ -1,17 +1,20 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { QuestStep } from "../types";
|
import { QuestStep, RichSegment } from "../types";
|
||||||
import { TextWithCoords } from "./TextWithCoords";
|
import { TextWithCoords } from "./TextWithCoords";
|
||||||
|
|
||||||
|
const DPLN_BASE = "https://www.dofuspourlesnoobs.com";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
questName: string;
|
questName: string;
|
||||||
questUrl: string | null;
|
questUrl: string | null;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
onClose: () => void;
|
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<QuestStep[]>([]);
|
const [steps, setSteps] = useState<QuestStep[]>([]);
|
||||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
||||||
const [expandedSteps, setExpandedSteps] = useState<Set<number>>(new Set());
|
const [expandedSteps, setExpandedSteps] = useState<Set<number>>(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 lines = step.text.split('\n').filter(l => l.trim().length > 0);
|
||||||
const needsTruncate = lines.length > 4;
|
const needsTruncate = lines.length > 4;
|
||||||
const displayText = needsTruncate && !expanded
|
|
||||||
? lines.slice(0, 4).join('\n')
|
|
||||||
: step.text;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={step.index} onClick={() => toggleStep(step.index)} style={{
|
<div key={step.index} onClick={() => toggleStep(step.index)} style={{
|
||||||
@ -213,7 +213,10 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
|
|||||||
fontSize: "12px", color: "#94a3b8",
|
fontSize: "12px", color: "#94a3b8",
|
||||||
lineHeight: 1.6, whiteSpace: "pre-wrap", wordBreak: "break-word",
|
lineHeight: 1.6, whiteSpace: "pre-wrap", wordBreak: "break-word",
|
||||||
}}>
|
}}>
|
||||||
<TextWithCoords text={displayText} />
|
{step.rich_text.length > 0 && (expanded || !needsTruncate)
|
||||||
|
? <RichText segments={step.rich_text} onSelectQuest={onSelectQuest} />
|
||||||
|
: <TextWithCoords text={needsTruncate && !expanded ? lines.slice(0, 4).join('\n') : step.text} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{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 (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
onClick={e => {
|
||||||
|
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}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <TextWithCoords key={i} text={seg.text} />;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function QuestHeader({ step }: { step: QuestStep }) {
|
function QuestHeader({ step }: { step: QuestStep }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@ -71,9 +71,14 @@ export interface SyncResult {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RichSegment =
|
||||||
|
| { type: "Text"; text: string }
|
||||||
|
| { type: "QuestLink"; text: string; href: string };
|
||||||
|
|
||||||
export interface QuestStep {
|
export interface QuestStep {
|
||||||
index: number;
|
index: number;
|
||||||
text: string;
|
text: string;
|
||||||
images: string[];
|
images: string[];
|
||||||
launch_position: string | null;
|
launch_position: string | null;
|
||||||
|
rich_text: RichSegment[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user