Compare commits

2 Commits

Author SHA1 Message Date
9ff8088ce5 docs: update README 2026-04-25 17:58:12 +02:00
3068b3e352 feat: highlight link to other quest or copy item name in quest details 2026-04-25 16:06:06 +02:00
9 changed files with 188 additions and 44 deletions

View File

@ -22,6 +22,8 @@
TougliGui est une application desktop permettant de suivre sa progression dans les guides Dofus [Tougli](https://docs.google.com/spreadsheets/d/1uL7svJ0E0MjhqHVLU7O4Q8v7iGwPd4bsI9qV-Pdhdds/edit?gid=0#gid=0 "lien du guide Tougli") (Dofus Argenté, Dofus Émeraude, Dofus Cauchemar, etc.). Les données sont synchronisées depuis Google Sheets et stockées localement dans SQLite. Chaque profil conserve sa propre progression indépendamment des autres. TougliGui est une application desktop permettant de suivre sa progression dans les guides Dofus [Tougli](https://docs.google.com/spreadsheets/d/1uL7svJ0E0MjhqHVLU7O4Q8v7iGwPd4bsI9qV-Pdhdds/edit?gid=0#gid=0 "lien du guide Tougli") (Dofus Argenté, Dofus Émeraude, Dofus Cauchemar, etc.). Les données sont synchronisées depuis Google Sheets et stockées localement dans SQLite. Chaque profil conserve sa propre progression indépendamment des autres.
![Overall](screenshots/overall.png)
### Page principale ### Page principale
![Page principale](screenshots/accueil.png) ![Page principale](screenshots/accueil.png)

BIN
screenshots/overall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

1
src-tauri/Cargo.lock generated
View File

@ -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",

View File

@ -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]

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)] #[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 …"

View File

@ -398,43 +398,34 @@ 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") { }
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()); return Some(cell.to_string());
} }
}
}
None None
} }

View File

@ -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 && (

View File

@ -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={{

View File

@ -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[];
} }