feat: add first version of TougliGui with same features as on google sheet
7
src-tauri/.gitignore
vendored
Normal 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
28
src-tauri/Cargo.toml
Normal 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
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
26
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 31 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 955 B |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
211
src-tauri/src/commands.rs
Normal 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 §ion.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
@ -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
@ -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
@ -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
@ -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
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||