feat: add first version of TougliGui with same features as on google sheet

This commit is contained in:
2026-04-21 22:02:20 +02:00
parent 79d5c4baaa
commit f571d8bb3f
53 changed files with 12416 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

7131
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "toughligui"
version = "0.1.0"
description = "TougliGui - Dofus guide tracker"
authors = ["Anthony Bouteiller"]
edition = "2021"
[lib]
name = "toughligui_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-http = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
csv = "1"
reqwest = { version = "0.12", features = ["blocking"] }
tokio = { version = "1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
dirs-next = "2"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,26 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-start-dragging",
"core:window:allow-set-always-on-top",
"core:window:allow-set-focus",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-set-resizable",
"core:window:allow-set-title",
"core:window:allow-set-decorations",
"core:window:allow-set-cursor-visible",
"core:window:allow-is-maximized",
"core:window:allow-is-minimized",
"core:window:allow-is-focused"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

211
src-tauri/src/commands.rs Normal file
View File

@ -0,0 +1,211 @@
use tauri::{AppHandle, Manager, State};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use rusqlite::Connection;
use crate::{db, parser};
pub struct DbState(pub Mutex<Connection>);
#[derive(Debug, Serialize, Deserialize)]
pub struct GuideListItem {
pub gid: String,
pub name: String,
pub last_synced_at: Option<String>,
pub total_quests: usize,
pub completed_quests: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SyncResult {
pub synced: usize,
pub errors: Vec<String>,
}
#[tauri::command]
pub fn get_profiles(state: State<DbState>) -> Result<Vec<db::Profile>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::get_profiles(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_profile(state: State<DbState>, name: String) -> Result<db::Profile, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::create_profile(&conn, &name).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_profile(state: State<DbState>, profile_id: String) -> Result<(), String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::delete_profile(&conn, &profile_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_completed_quests(state: State<DbState>, profile_id: String) -> Result<Vec<String>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::get_completed_quests(&conn, &profile_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn toggle_quest(state: State<DbState>, profile_id: String, quest_name: String) -> Result<bool, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::toggle_quest(&conn, &profile_id, &quest_name).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_guides_list(state: State<DbState>, profile_id: String) -> Result<Vec<GuideListItem>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let guides = db::get_guides(&conn).map_err(|e| e.to_string())?;
let completed = db::get_completed_quests(&conn, &profile_id).map_err(|e| e.to_string())?;
let completed_set: std::collections::HashSet<String> = completed.into_iter().collect();
let items = guides.into_iter().map(|g| {
let data: parser::GuideData = serde_json::from_str(&g.data).unwrap_or_else(|_| parser::GuideData {
name: g.name.clone(),
gid: g.gid.clone(),
effect: String::new(),
recommended_level: None,
combat_legend: vec![],
resources: vec![],
sections: vec![],
});
let all_quests = collect_quest_names(&data);
let total = all_quests.len();
let completed_count = all_quests.iter().filter(|q| completed_set.contains(*q)).count();
GuideListItem {
gid: g.gid,
name: g.name,
last_synced_at: g.last_synced_at,
total_quests: total,
completed_quests: completed_count,
}
}).collect();
Ok(items)
}
#[tauri::command]
pub fn get_guide(state: State<DbState>, gid: String) -> Result<parser::GuideData, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let guides = db::get_guides(&conn).map_err(|e| e.to_string())?;
if let Some(g) = guides.into_iter().find(|g| g.gid == gid) {
serde_json::from_str(&g.data).map_err(|e| e.to_string())
} else {
Err(format!("Guide {} not found", gid))
}
}
#[tauri::command]
pub async fn sync_guides(state: State<'_, DbState>) -> Result<SyncResult, String> {
let mut synced = 0;
let mut errors = Vec::new();
for (gid, name) in parser::TABS {
let gid = gid.to_string();
let name = name.to_string();
// Run blocking HTTP+parse in a dedicated thread to avoid runtime conflict
let result = tokio::task::spawn_blocking({
let gid = gid.clone();
let name = name.clone();
move || -> Result<String, String> {
let csv = parser::fetch_csv(&gid)?;
let links = parser::fetch_quest_links(&gid);
let data = parser::parse_guide_with_links(&gid, &name, &csv, &links);
serde_json::to_string(&data).map_err(|e| e.to_string())
}
}).await.map_err(|e| e.to_string())?;
match result {
Ok(json) => {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::upsert_guide(&conn, &gid, &name, &json).map_err(|e| e.to_string())?;
synced += 1;
}
Err(e) => {
errors.push(format!("{}: {}", name, e));
}
}
}
Ok(SyncResult { synced, errors })
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TabInfo {
pub gid: String,
pub name: String,
}
#[tauri::command]
pub fn get_tabs_list() -> Vec<TabInfo> {
parser::TABS.iter().map(|(gid, name)| TabInfo {
gid: gid.to_string(),
name: name.to_string(),
}).collect()
}
#[tauri::command]
pub async fn sync_single_guide(state: State<'_, DbState>, gid: String, name: String) -> Result<(), String> {
let json = tokio::task::spawn_blocking({
let gid = gid.clone();
let name = name.clone();
move || -> Result<String, String> {
let csv = parser::fetch_csv(&gid)?;
let links = parser::fetch_quest_links(&gid);
let data = parser::parse_guide_with_links(&gid, &name, &csv, &links);
serde_json::to_string(&data).map_err(|e| e.to_string())
}
}).await.map_err(|e| e.to_string())??;
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::upsert_guide(&conn, &gid, &name, &json).map_err(|e| e.to_string())?;
Ok(())
}
// This remains for backwards compat but delegates to per-guide approach
#[tauri::command]
pub fn has_guides(state: State<DbState>) -> Result<bool, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let guides = db::get_guides(&conn).map_err(|e| e.to_string())?;
Ok(!guides.is_empty())
}
#[tauri::command]
pub fn get_setting(state: State<DbState>, key: String) -> Result<Option<String>, String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
Ok(db::get_setting(&conn, &key))
}
#[tauri::command]
pub fn set_setting(state: State<DbState>, key: String, value: String) -> Result<(), String> {
let conn = state.0.lock().map_err(|e| e.to_string())?;
db::set_setting(&conn, &key, &value).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> {
if let Some(win) = app.get_webview_window("main") {
win.set_always_on_top(value).map_err(|e| e.to_string())
} else {
Err("Window not found".to_string())
}
}
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
let mut names = Vec::new();
for section in &data.sections {
for item in &section.items {
match item {
parser::SectionItem::Quest(q) => names.push(q.name.clone()),
parser::SectionItem::Group(g) => {
for q in &g.quests { names.push(q.name.clone()); }
}
_ => {}
}
}
}
names
}

163
src-tauri/src/db.rs Normal file
View File

@ -0,0 +1,163 @@
use rusqlite::{Connection, Result, params};
use serde::{Deserialize, Serialize};
use chrono::Utc;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Profile {
pub id: String,
pub name: String,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GuideRow {
pub gid: String,
pub name: String,
pub data: String,
pub last_synced_at: Option<String>,
}
pub fn get_db_path() -> String {
let data_dir = dirs_next::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("toughligui");
std::fs::create_dir_all(&data_dir).ok();
data_dir.join("toughligui.db").to_string_lossy().to_string()
}
pub fn open() -> Result<Connection> {
let path = get_db_path();
let conn = Connection::open(&path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
Ok(conn)
}
pub fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS guides (
gid TEXT PRIMARY KEY,
name TEXT NOT NULL,
data TEXT NOT NULL,
last_synced_at TEXT
);
CREATE TABLE IF NOT EXISTS quest_completions (
profile_id TEXT NOT NULL,
quest_name TEXT NOT NULL,
completed_at TEXT NOT NULL,
PRIMARY KEY (profile_id, quest_name),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
")?;
Ok(())
}
pub fn get_profiles(conn: &Connection) -> Result<Vec<Profile>> {
let mut stmt = conn.prepare("SELECT id, name, created_at FROM profiles ORDER BY created_at ASC")?;
let rows = stmt.query_map([], |row| {
Ok(Profile {
id: row.get(0)?,
name: row.get(1)?,
created_at: row.get(2)?,
})
})?;
rows.collect()
}
pub fn create_profile(conn: &Connection, name: &str) -> Result<Profile> {
let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO profiles (id, name, created_at) VALUES (?1, ?2, ?3)",
params![id, name, created_at],
)?;
Ok(Profile { id, name: name.to_string(), created_at })
}
pub fn delete_profile(conn: &Connection, profile_id: &str) -> Result<()> {
conn.execute("DELETE FROM profiles WHERE id = ?1", params![profile_id])?;
Ok(())
}
pub fn get_completed_quests(conn: &Connection, profile_id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT quest_name FROM quest_completions WHERE profile_id = ?1"
)?;
let rows = stmt.query_map(params![profile_id], |row| row.get(0))?;
rows.collect()
}
pub fn toggle_quest(conn: &Connection, profile_id: &str, quest_name: &str) -> Result<bool> {
let exists: bool = conn.query_row(
"SELECT COUNT(*) FROM quest_completions WHERE profile_id = ?1 AND quest_name = ?2",
params![profile_id, quest_name],
|row| row.get::<_, i64>(0),
).map(|c| c > 0)?;
if exists {
conn.execute(
"DELETE FROM quest_completions WHERE profile_id = ?1 AND quest_name = ?2",
params![profile_id, quest_name],
)?;
Ok(false)
} else {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO quest_completions (profile_id, quest_name, completed_at) VALUES (?1, ?2, ?3)",
params![profile_id, quest_name, now],
)?;
Ok(true)
}
}
pub fn upsert_guide(conn: &Connection, gid: &str, name: &str, data: &str) -> Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO guides (gid, name, data, last_synced_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(gid) DO UPDATE SET name=excluded.name, data=excluded.data, last_synced_at=excluded.last_synced_at",
params![gid, name, data, now],
)?;
Ok(())
}
pub fn get_guides(conn: &Connection) -> Result<Vec<GuideRow>> {
let mut stmt = conn.prepare("SELECT gid, name, data, last_synced_at FROM guides ORDER BY rowid ASC")?;
let rows = stmt.query_map([], |row| {
Ok(GuideRow {
gid: row.get(0)?,
name: row.get(1)?,
data: row.get(2)?,
last_synced_at: row.get(3)?,
})
})?;
rows.collect()
}
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
conn.query_row(
"SELECT value FROM settings WHERE key = ?1",
params![key],
|row| row.get(0),
).ok()
}
pub fn set_setting(conn: &Connection, key: &str, value: &str) -> Result<()> {
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value=excluded.value",
params![key, value],
)?;
Ok(())
}

37
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,37 @@
mod db;
mod parser;
mod commands;
use commands::DbState;
use std::sync::Mutex;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let conn = db::open().expect("Failed to open database");
db::migrate(&conn).expect("Failed to migrate database");
app.manage(DbState(Mutex::new(conn)));
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_profiles,
commands::create_profile,
commands::delete_profile,
commands::get_completed_quests,
commands::toggle_quest,
commands::get_guides_list,
commands::get_guide,
commands::sync_guides,
commands::get_tabs_list,
commands::sync_single_guide,
commands::has_guides,
commands::get_setting,
commands::set_setting,
commands::set_always_on_top,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
toughligui_lib::run()
}

542
src-tauri/src/parser.rs Normal file
View File

@ -0,0 +1,542 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GuideData {
pub name: String,
pub gid: String,
pub effect: String,
pub recommended_level: Option<u32>,
pub combat_legend: Vec<CombatType>,
pub resources: Vec<Resource>,
pub sections: Vec<Section>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CombatType {
pub name: String,
pub column: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Resource {
pub quantity: u32,
pub name: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Section {
pub name: String,
pub items: Vec<SectionItem>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum SectionItem {
Quest(QuestItem),
Instruction(InstructionItem),
Group(GroupItem),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QuestItem {
pub name: String,
pub completed: bool,
pub combat_indicators: Vec<CombatIndicator>,
pub note: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CombatIndicator {
pub combat_type: String,
pub count: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct InstructionItem {
pub text: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GroupItem {
pub note: Option<String>,
pub quests: Vec<QuestItem>,
}
const SHEET_ID: &str = "1uL7svJ0E0MjhqHVLU7O4Q8v7iGwPd4bsI9qV-Pdhdds";
pub static TABS: &[(&str, &str)] = &[
("474870200", "Dofawa"),
("743703882", "Dofus Argenté"),
("103963898", "Dofus Cawotte"),
("1075294690", "Dokoko"),
("1567240526", "Dofus des Veilleurs"),
("1011508069", "Dofus Emeraude"),
("2045137654", "Dofus Pourpre"),
("1967508888", "Domakuro"),
("1382359191", "Dorigami"),
("1413546794", "Dofus Turquoise"),
("1641656252", "Dofus des Glaçes"),
("953522228", "Dofus Abyssal"),
("818597042", "Dofoozbz"),
("1021129660", "Dofus Nébuleux"),
("595670723", "Dofus Vulbis"),
("544349966", "Dofus Tacheté"),
("1150302145", "Dofus Forgelave"),
("882278553", "Dofus Ebène"),
("200570588", "Dofus Ivoire"),
("1209269839", "Dofus Ocre"),
("462784268", "Dofus Argenté Scintillant"),
("1543573905", "Dofus Cauchemar"),
("1007491889", "Dom de Pin"),
("1047555165", "Dofus Sylvestre"),
("2105601828", "Dofus Cacao"),
("474510463", "Dokille"),
("62476099", "Dolmanax"),
("1873654554", "Dotruche"),
("360188709", "Dofus Kaliptus"),
];
fn make_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())
}
pub fn fetch_csv(gid: &str) -> Result<String, String> {
let url = format!(
"https://docs.google.com/spreadsheets/d/{}/export?format=csv&gid={}",
SHEET_ID, gid
);
make_client()?
.get(&url)
.send()
.map_err(|e| e.to_string())?
.text()
.map_err(|e| e.to_string())
}
/// Fetch the sheet edit page HTML and extract quest_name → url pairs.
/// Pattern in Google Sheets JS: LS{url}]]LS{name}]]F
pub fn fetch_quest_links(gid: &str) -> std::collections::HashMap<String, String> {
let url = format!(
"https://docs.google.com/spreadsheets/d/{}/edit?gid={}",
SHEET_ID, gid
);
let html = match make_client().and_then(|c| c.get(&url).send().map_err(|e| e.to_string())).and_then(|r| r.text().map_err(|e| e.to_string())) {
Ok(h) => h,
Err(_) => return std::collections::HashMap::new(),
};
let mut map = std::collections::HashMap::new();
// Regex-free parsing: find all "LS{url}]]LS{name}]]F" occurrences
let marker = "LS";
let end_marker = "]]LS";
let end_f = "]]F";
let mut pos = 0;
while let Some(start) = html[pos..].find("LShttps://www.dofuspourlesnoobs.com/") {
let abs = pos + start + 2; // skip "LS"
// Find end of URL
if let Some(url_end) = html[abs..].find("]]LS") {
let quest_url = html[abs..abs + url_end].to_string();
let name_start = abs + url_end + 4; // skip "]]LS"
if let Some(name_end) = html[name_start..].find("]]F") {
let raw_name = &html[name_start..name_start + name_end];
// Decode \\u00XX unicode escapes
let name = decode_unicode_escapes(raw_name);
map.entry(name).or_insert(quest_url);
}
pos = abs + url_end + 4;
} else {
break;
}
}
let _ = (marker, end_marker, end_f);
map
}
fn slugify(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
} else {
// apostrophes, spaces, accents, punctuation all become hyphens
if !slug.ends_with('-') {
slug.push('-');
}
}
}
let slug = slug.trim_matches('-').to_string();
format!("https://www.dofuspourlesnoobs.com/{}.html", slug)
}
fn decode_unicode_escapes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('u') => {
let hex: String = chars.by_ref().take(4).collect();
if let Ok(n) = u32::from_str_radix(&hex, 16) {
if let Some(decoded) = char::from_u32(n) {
result.push(decoded);
continue;
}
}
result.push_str("\\u");
result.push_str(&hex);
}
// Google Sheets double-escapes: \\u0027 → '
Some('\\') => {
if chars.peek() == Some(&'u') {
chars.next(); // consume 'u'
let hex: String = chars.by_ref().take(4).collect();
if let Ok(n) = u32::from_str_radix(&hex, 16) {
if let Some(decoded) = char::from_u32(n) {
result.push(decoded);
continue;
}
}
result.push_str("\\u");
result.push_str(&hex);
} else {
result.push('\\');
}
}
Some(other) => { result.push('\\'); result.push(other); }
None => result.push('\\'),
}
} else {
result.push(c);
}
}
result
}
fn get_cell(row: &[String], col: usize) -> &str {
row.get(col).map(|s| s.trim()).unwrap_or("")
}
fn find_checkbox_col(row: &[String]) -> Option<(usize, bool)> {
for (i, cell) in row.iter().enumerate() {
let v = cell.trim().to_uppercase();
if v == "FALSE" || v == "TRUE" {
return Some((i, v == "TRUE"));
}
}
None
}
fn parse_level_from_cell(cell: &str) -> Option<u32> {
// Only parse from the specific cell containing "niveau recommandé", not the full row
let lower = cell.to_lowercase();
if lower.contains("niveau recommandé") || lower.contains("niveau recommande") {
// Extract only the number immediately after the colon/space
if let Some(colon) = lower.find(':') {
let after = lower[colon + 1..].trim_start();
let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
return digits.parse().ok();
}
}
None
}
fn is_resource_row(row: &[String]) -> Option<(u32, String)> {
// Resources live in the right portion of the sheet (cols 36+)
// Pattern: quantity in some col, empty cols, then name
for start_col in 36..row.len().saturating_sub(4) {
let qty_str = row.get(start_col).map(|s| s.trim()).unwrap_or("");
if qty_str.is_empty() { continue; }
if let Ok(qty) = qty_str.parse::<u32>() {
// Look for the name within the next few cols
for name_col in (start_col + 1)..=(start_col + 5).min(row.len().saturating_sub(1)) {
let name = row.get(name_col).map(|s| s.trim()).unwrap_or("");
if !name.is_empty() && !name.to_uppercase().contains("KAMAS") || !name.is_empty() {
// Check that cols between qty and name are empty
let all_empty = ((start_col + 1)..name_col)
.all(|c| row.get(c).map(|s| s.trim()).unwrap_or("").is_empty());
if all_empty {
return Some((qty, name.to_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(),
});
}
indicators
}
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
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());
}
}
}
None
}
pub fn parse_guide(gid: &str, name: &str, csv: &str) -> GuideData {
parse_guide_with_links(gid, name, csv, &std::collections::HashMap::new())
}
pub fn parse_guide_with_links(
gid: &str,
name: &str,
csv: &str,
links: &std::collections::HashMap<String, String>,
) -> GuideData {
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.flexible(true)
.from_reader(csv.as_bytes());
let rows: Vec<Vec<String>> = reader
.records()
.filter_map(|r| r.ok())
.map(|r| r.iter().map(|s| s.to_string()).collect())
.collect();
let mut effect = String::new();
let mut recommended_level: Option<u32> = None;
let mut resources: Vec<Resource> = Vec::new();
let mut combat_legend: Vec<CombatType> = Vec::new();
let mut sections: Vec<Section> = Vec::new();
// Phase 1: Extract effect (first non-empty cell in col 17+ within first 10 rows)
for row in rows.iter().take(8) {
for col in 14..row.len() {
let cell = get_cell(row, col);
if !cell.is_empty() && cell.len() > 20 && !cell.contains("Multiplicateur") {
effect = cell.to_string();
break;
}
}
if !effect.is_empty() { break; }
}
// Phase 2: Scan entire sheet
let mut in_quest_area = false;
let mut current_section = Section { name: "Prérequis".to_string(), items: Vec::new() };
let mut pending_group: Option<(Option<String>, Vec<QuestItem>)> = None;
let mut legend_row_idx: Option<usize> = None;
for (row_idx, row) in rows.iter().enumerate() {
// Extract resources from right side
if let Some((qty, rname)) = is_resource_row(row) {
if !rname.is_empty() {
resources.push(Resource { quantity: qty, name: rname });
}
}
// Look for recommended level — check each cell individually to avoid cross-cell digit merging
if recommended_level.is_none() {
for cell in row.iter() {
if let Some(lvl) = parse_level_from_cell(cell.trim()) {
recommended_level = Some(lvl);
break;
}
}
}
// Detect legend row (contains "Bashing" or "bash" or "Combat solo")
let row_text_low: String = row.iter().map(|s| s.to_lowercase()).collect::<Vec<_>>().join("|");
if row_text_low.contains("bash") || row_text_low.contains("combat solo") {
legend_row_idx = Some(row_idx);
combat_legend.clear();
for (col, cell) in row.iter().enumerate() {
let t = cell.trim();
if !t.is_empty() && col < 36 {
combat_legend.push(CombatType { name: t.to_string(), column: col });
}
}
// Check next row for secondary legend entries
if let Some(next_row) = rows.get(row_idx + 1) {
let has_checkbox = find_checkbox_col(next_row).is_some();
if !has_checkbox {
for (col, cell) in next_row.iter().enumerate() {
let t = cell.trim();
if !t.is_empty() && col < 36 && !combat_legend.iter().any(|c| c.column == col) {
combat_legend.push(CombatType { name: t.to_string(), column: col });
}
}
}
}
}
// Detect section markers: "Prérequis", "Les quêtes"
for col in 0..row.len().min(5) {
let cell = get_cell(row, col);
if cell == "Les quêtes" || cell == "Prérequis" || cell == "Les quetes" {
// Flush pending group
if let Some((note, quests)) = pending_group.take() {
if !quests.is_empty() {
current_section.items.push(SectionItem::Group(GroupItem { note, quests }));
}
}
if !current_section.items.is_empty() || !sections.is_empty() {
let prev = std::mem::replace(&mut current_section, Section {
name: cell.to_string(),
items: Vec::new(),
});
if !prev.items.is_empty() { sections.push(prev); }
} else {
current_section.name = cell.to_string();
}
in_quest_area = true;
}
}
// Zone header: non-empty text at col 14-18, no checkbox
if in_quest_area && find_checkbox_col(row).is_none() {
for col in 14..row.len().min(20) {
let cell = get_cell(row, col);
if !cell.is_empty() && cell.len() < 50
&& !cell.contains("recommandé")
&& !cell.contains("Rappel")
&& !cell.contains("Lorsque")
&& !cell.contains("quêtes sont dans")
&& !cell.to_lowercase().contains("bash")
&& !cell.to_lowercase().contains("combat")
&& !cell.to_lowercase().contains("donjon")
&& !cell.to_lowercase().contains("horaire")
{
// Flush pending group
if let Some((note, quests)) = pending_group.take() {
if !quests.is_empty() {
current_section.items.push(SectionItem::Group(GroupItem { note, quests }));
}
}
current_section.items.push(SectionItem::Instruction(InstructionItem {
text: format!("__ZONE__:{}", cell),
}));
break;
}
}
}
// Instruction lines (non-quest text within quest area)
if in_quest_area && find_checkbox_col(row).is_none() {
// Check col 5-9 for group notes or instructions
for col in 5..row.len().min(12) {
let cell = get_cell(row, col);
if !cell.is_empty() && cell.len() > 5 && col < 36 {
let lower = cell.to_lowercase();
// Group note pattern
if cell.contains("même zone") || cell.contains("ensemble") {
if let Some((_, ref mut quests)) = pending_group {
// Already in a group - flush and start new
let old = pending_group.take().unwrap();
if !old.1.is_empty() {
current_section.items.push(SectionItem::Group(GroupItem { note: old.0, quests: old.1 }));
}
}
pending_group = Some((Some(cell.to_string()), Vec::new()));
break;
}
// General instruction
if !lower.contains("recommandé")
&& !lower.contains("rappel")
&& !lower.contains("bash")
&& !lower.contains("combat")
&& !lower.contains("donjon")
{
current_section.items.push(SectionItem::Instruction(InstructionItem {
text: cell.to_string(),
}));
}
break;
}
}
}
// Quest rows
if let Some((checkbox_col, completed)) = find_checkbox_col(row) {
in_quest_area = true;
let name_col = checkbox_col + 1;
let quest_name = get_cell(row, name_col).to_string();
if quest_name.is_empty() { continue; }
let combat_indicators = parse_combat_indicators(row, &combat_legend, checkbox_col);
let note = extract_note(row, name_col, &combat_legend);
let url = Some(links.get(&quest_name).cloned()
.unwrap_or_else(|| slugify(&quest_name)));
let quest = QuestItem {
name: quest_name,
completed,
combat_indicators,
note,
url,
};
// Col 7 = grouped (one extra indent vs col 6)
if checkbox_col >= 7 {
if pending_group.is_none() {
pending_group = Some((None, Vec::new()));
}
if let Some((_, ref mut quests)) = pending_group {
quests.push(quest);
}
} else {
// Flush any pending group first
if let Some((note, quests)) = pending_group.take() {
if !quests.is_empty() {
current_section.items.push(SectionItem::Group(GroupItem { note, quests }));
}
}
current_section.items.push(SectionItem::Quest(quest));
}
}
}
// Flush remaining
if let Some((note, quests)) = pending_group.take() {
if !quests.is_empty() {
current_section.items.push(SectionItem::Group(GroupItem { note, quests }));
}
}
if !current_section.items.is_empty() {
sections.push(current_section);
}
GuideData {
name: name.to_string(),
gid: gid.to_string(),
effect,
recommended_level,
combat_legend,
resources,
sections,
}
}

41
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,41 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "TougliGui",
"version": "0.1.0",
"identifier": "com.anthony.toughligui",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "TougliGui",
"width": 1100,
"height": 720,
"minWidth": 800,
"minHeight": 500,
"decorations": false,
"transparent": false,
"alwaysOnTop": true,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}