4 Commits

Author SHA1 Message Date
b0e6d09301 fix: correct artifact names in release job
Some checks failed
Release / create-release (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / build-windows (push) Has been cancelled
Release / build-macos (push) Has been cancelled
2026-04-25 23:02:38 +02:00
e3095ecf10 fix: add missing vitest import
Some checks failed
Release / create-release (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / build-windows (push) Has been cancelled
Release / build-macos (push) Has been cancelled
2026-04-25 18:02:08 +02:00
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
11 changed files with 205 additions and 45 deletions

View File

@ -81,4 +81,19 @@ jobs:
- name: Télécharger les artefacts Windows
uses: actions/download-artifact@v4
with:
name: w
name: windows-build
path: artifacts/windows
- name: Télécharger les artefacts Linux
uses: actions/download-artifact@v4
with:
name: linux-build
path: artifacts/linux
- name: Créer la release GitHub
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/windows/**
artifacts/linux/**
generate_release_notes: true

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.
![Overall](screenshots/overall.png)
### Page principale
![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",
"csv",
"dirs-next",
"ego-tree",
"gtk",
"regex",
"reqwest 0.12.28",

View File

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

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
}

View File

@ -1 +1,2 @@
import { vi } from 'vitest';
export const invoke = vi.fn().mockResolvedValue(null);

View File

@ -53,6 +53,7 @@ export default function GuideView() {
questUrl={selectedQuest.url}
profileId={activeProfileId}
onClose={() => setSelectedQuest(null)}
onSelectQuest={setSelectedQuest}
/>
</div>
);
@ -380,11 +381,6 @@ function QuestRow({ quest, completed, onToggle, onSelect, indent }: {
>
{quest.name}
</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>
{hasPreviewSection && (

View File

@ -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<QuestStep[]>([]);
const [completedSteps, setCompletedSteps] = 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 needsTruncate = lines.length > 4;
const displayText = needsTruncate && !expanded
? lines.slice(0, 4).join('\n')
: step.text;
return (
<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",
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>
{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 }) {
return (
<div style={{

View File

@ -71,9 +71,14 @@ export interface SyncResult {
errors: string[];
}
export type RichSegment =
| { type: "Text"; text: string }
| { type: "QuestLink"; text: string; href: string };
export interface QuestStep {
index: number;
text: string;
images: string[];
launch_position: string | null;
rich_text: RichSegment[];
}