diff --git a/Cargo.lock b/Cargo.lock index ef411d95..265aada6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2929,9 +2929,9 @@ dependencies = [ [[package]] name = "ltk_overlay" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08b42196142f39d4d009d442d5e0af681159f924b87265e69ac061555ccd64a8" +checksum = "c678f83a2a94f8a96622f555d0e6764c6f71a599d1ced162538a8357518ace9c" dependencies = [ "byteorder", "camino", diff --git a/Cargo.toml b/Cargo.toml index e33cbbea..5322fc26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] members = ["src-tauri"] -resolver = "2" \ No newline at end of file +resolver = "2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ae04e28a..187dcbb1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,7 +30,7 @@ ltk_modpkg = { version = "0.5.0", features = ["project"] } ltk_mod_project = { version = "0.4.1" } ltk_mod_core = { version = "0.1.0" } ltk_fantome = { version = "0.5.1" } -ltk_overlay = { version = "0.2.7" } +ltk_overlay = { version = "0.2.8" } ltk_wad = "0.2.12" ltk_file = { version = "0.2.8", features = ["serde"] } diff --git a/src-tauri/src/commands/mods.rs b/src-tauri/src/commands/mods.rs index 5ba851f7..ef40a867 100644 --- a/src-tauri/src/commands/mods.rs +++ b/src-tauri/src/commands/mods.rs @@ -1,11 +1,10 @@ -use crate::error::{AppError, AppResult, IpcResult, MutexResultExt}; +use crate::error::{AppError, AppResult, IpcResult, MutexResultExt, Utf8PathExt}; use crate::mods::{ inspect_modpkg_file, BulkInstallResult, InstalledMod, ModLibraryState, ModWadReport, ModpkgInfo, WadReportState, }; use crate::patcher::PatcherState; use crate::state::SettingsState; -use camino::Utf8PathBuf; use std::collections::HashMap; use tauri::State; @@ -33,7 +32,11 @@ pub fn install_mod( let result: AppResult = (|| { reject_if_patcher_running(&patcher)?; let settings = settings.0.lock().mutex_err()?.clone(); - library.0.install_mod_from_package(&settings, &file_path) + let installed = library.0.install_mod_from_package(&settings, &file_path)?; + library + .0 + .spawn_categorization(&settings, vec![installed.id.clone()]); + Ok(installed) })(); result.into() } @@ -49,7 +52,12 @@ pub fn install_mods( let result: AppResult = (|| { reject_if_patcher_running(&patcher)?; let settings = settings.0.lock().mutex_err()?.clone(); - library.0.install_mods_from_packages(&settings, &file_paths) + let result = library + .0 + .install_mods_from_packages(&settings, &file_paths)?; + let ids = result.installed.iter().map(|m| m.id.clone()).collect(); + library.0.spawn_categorization(&settings, ids); + Ok(result) })(); result.into() } @@ -249,24 +257,27 @@ pub fn analyze_mod_wads( ) -> IpcResult { let result: AppResult = (|| { let settings_snapshot = settings.0.lock().mutex_err()?.clone(); - let game_dir = crate::overlay::resolve_game_dir(&settings_snapshot)?; + let game_dir = crate::utils::game::resolve_game_dir(&settings_snapshot)?; let (profile_dir, mut enabled_mod) = library .0 .build_single_mod_provider(&settings_snapshot, &mod_id)?; - let utf8_game_dir = Utf8PathBuf::from_path_buf(game_dir) - .map_err(|p| AppError::InvalidPath(format!("Non-UTF8 game dir: {}", p.display())))?; - let utf8_state_dir = Utf8PathBuf::from_path_buf(profile_dir) - .map_err(|p| AppError::InvalidPath(format!("Non-UTF8 profile dir: {}", p.display())))?; + let game_dir = game_dir.try_into_utf8("game directory")?; + let state_dir = profile_dir.try_into_utf8("profile directory")?; let upstream = ltk_overlay::OverlayBuilder::analyze_single_mod( - &utf8_game_dir, - &utf8_state_dir, + &game_dir, + &state_dir, &mut enabled_mod, ) .map_err(|e| AppError::Other(format!("Mod analysis failed: {}", e)))?; - let report = ModWadReport::from_upstream(upstream); + let mut report = ModWadReport::from_upstream(upstream); + library.0.apply_precise_categorization( + &settings_snapshot, + game_dir.as_std_path(), + &mut report, + ); let mut store = reports.0.lock().mutex_err()?; store.upsert(report.clone())?; Ok(store.get(&report.mod_id).unwrap_or(report)) diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 1eba18ab..e9fa0dc0 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,6 +1,6 @@ use crate::error::{AppResult, IpcResult, MutexResultExt}; -use crate::overlay::{list_game_wads, resolve_game_dir}; use crate::state::{save_settings_to_disk, Settings, SettingsState}; +use crate::utils::game::{list_game_wads, resolve_game_dir}; use std::path::PathBuf; use tauri::{AppHandle, State}; use tauri_plugin_autostart::ManagerExt; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 71c02b3b..652fce87 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -1,4 +1,6 @@ +use camino::Utf8PathBuf; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use thiserror::Error; use ts_rs::TS; @@ -336,6 +338,20 @@ impl MutexResultExt for Result> { } } +/// Extension trait for converting an owned `PathBuf` into a `Utf8PathBuf`, +/// mapping a non-UTF-8 path to an [`AppError::InvalidPath`] labeled with what +/// the path represents (e.g. `"game directory"`). +pub trait Utf8PathExt { + fn try_into_utf8(self, label: &str) -> AppResult; +} + +impl Utf8PathExt for PathBuf { + fn try_into_utf8(self, label: &str) -> AppResult { + Utf8PathBuf::from_path_buf(self) + .map_err(|p| AppError::InvalidPath(format!("Non-UTF-8 {label}: {}", p.display()))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -488,6 +504,14 @@ mod tests { assert_eq!(*guard, 42); } + #[test] + fn utf8_path_ext_converts_valid_path() { + let utf8 = PathBuf::from("/tmp/foo/bar") + .try_into_utf8("test path") + .unwrap(); + assert_eq!(utf8.as_str(), "/tmp/foo/bar"); + } + #[test] fn app_error_response_context_skipped_when_none() { let resp = AppErrorResponse::new(ErrorCode::Io, "err"); diff --git a/src-tauri/src/mods/categorize.rs b/src-tauri/src/mods/categorize.rs new file mode 100644 index 00000000..2a6179d9 --- /dev/null +++ b/src-tauri/src/mods/categorize.rs @@ -0,0 +1,786 @@ +//! Mod content → category classification. +//! +//! Two classification paths feed the same [`DerivedCategorization`]: +//! +//! - **Precise** — [`DerivedCategorization::from_chunk_paths`] reads a modpkg's +//! internal chunk paths (e.g. `assets/characters/aatrox/skins/skin01/...`). +//! The chunk path names the content unambiguously, so it distinguishes +//! champions, maps, emotes, summoner icons, ward skins, TFT and companions — +//! and never confuses a champion base-particle edit for a map skin. +//! - **Coarse** — [`ModWadReport::derive_categorization`] works only from the +//! WAD footprint (`affected_wads`, e.g. `DATA/FINAL/Champions/Aatrox.wad.client`). +//! Used for `.fantome` mods whose chunks are hash-keyed and carry no readable +//! path. Reliable for champions/maps (the WAD filename names them) but blind +//! to the shared-WAD categories, which all live in `Global`/`UI`. +//! +//! Both are pure and I/O-free; the champion roster (which names are real +//! champions vs. wards/pets/structures) is supplied by the caller via +//! [`ChampionRoster::from_internal_names`]. + +use super::wad_reports::ModWadReport; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeSet, HashMap}; +use ts_rs::TS; + +/// Categories derived from a mod's contents. Each list is de-duplicated and +/// sorted. Champions hold display names (e.g. `"Aatrox"`); maps and tags hold +/// well-known slugs (e.g. `"summoners-rift"`, `"champion-skin"`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct DerivedCategorization { + pub champions: Vec, + pub maps: Vec, + pub tags: Vec, +} + +impl DerivedCategorization { + /// `true` when nothing was classified — caller may fall back to a coarser + /// source rather than persist an empty result. + pub fn is_empty(&self) -> bool { + self.champions.is_empty() && self.maps.is_empty() && self.tags.is_empty() + } +} + +/// A content-category tag, stored as its slug in [`DerivedCategorization::tags`]. +#[derive(Debug, Clone, Copy)] +enum Tag { + ChampionSkin, + MapSkin, + WardSkin, + Emote, + SummonerIcon, + Ui, + Tft, + Companion, + /// Whole-mod fallback when content exists but matched no specific rule. + Misc, +} + +impl Tag { + /// The serialized slug written into [`DerivedCategorization::tags`]. + fn slug(self) -> &'static str { + match self { + Tag::ChampionSkin => "champion-skin", + Tag::MapSkin => "map-skin", + Tag::WardSkin => "ward-skin", + Tag::Emote => "emote", + Tag::SummonerIcon => "summoner-icon", + Tag::Ui => "ui", + Tag::Tft => "tft", + Tag::Companion => "companion", + Tag::Misc => "misc", + } + } +} + +/// The set of real champions, keyed by normalized internal name. Distinguishes +/// `characters/aatrox` (a champion) from `characters/sightward` (a ward) or +/// `characters/annietibbers` (a summon) so the classifier never emits a champion +/// for a non-champion entity. +#[derive(Debug, Clone, Default)] +pub struct ChampionRoster { + display_by_norm: HashMap, +} + +impl ChampionRoster { + /// Build from the champion WAD stems under `DATA/FINAL/Champions` (e.g. + /// `"Aatrox"`, `"MonkeyKing"`). Localized variants collapse onto the same + /// key, and the display-name overrides are applied here. + pub fn from_internal_names(names: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let mut display_by_norm = HashMap::new(); + for name in names { + let name = name.as_ref(); + let key = norm_key(name); + if key.is_empty() { + continue; + } + display_by_norm + .entry(key) + .or_insert_with(|| champion_display_name(name)); + } + Self { display_by_norm } + } + + /// Display name for an internal champion name (`"aatrox"` → `"Aatrox"`), or + /// `None` if the name isn't a champion. + fn lookup(&self, internal_name: &str) -> Option<&str> { + self.display_by_norm + .get(&norm_key(internal_name)) + .map(String::as_str) + } +} + +/// Map a champion's internal name to its display name (`"MonkeyKing"` → +/// `"Wukong"`); names without an override pass through unchanged. +fn champion_display_name(internal: &str) -> String { + match internal { + "MonkeyKing" => "Wukong".to_string(), + other => other.to_string(), + } +} + +/// A non-champion `characters/` entity that is a placeable ward (sight ward, +/// control ward / `jammerdevice`, or a `*trinket`). +fn is_ward_entity(name: &str) -> bool { + name.contains("ward") || name.ends_with("trinket") || name == "jammerdevice" +} + +/// The first known map slug among `segments`, case-insensitively (`"Map11"` → +/// `"summoners-rift"`). Only confident mappings are named; an unrecognized +/// `mapNN` yields `None` so the caller emits the generic `map-skin` tag without +/// guessing a wrong mode. Shared by both classification paths. +fn map_slug_from_segments(segments: &[String]) -> Option<&'static str> { + segments + .iter() + .find_map(|s| match s.to_ascii_lowercase().as_str() { + "map11" => Some("summoners-rift"), + "map12" => Some("aram"), + _ => None, + }) +} + +/// Normalization key for de-duplicating derived values against each other and +/// against user-declared metadata: lowercase, alphanumerics only. Lets a +/// derived `"Aatrox"` collapse against a user-typed `"aatrox"`. +fn norm_key(s: &str) -> String { + s.chars() + .filter(char::is_ascii_alphanumeric) + .flat_map(char::to_lowercase) + .collect() +} + +/// Collapse a sorted set on its normalized key, keeping the first occurrence. +fn dedup_normalized(values: BTreeSet) -> Vec { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + for v in values { + if seen.insert(norm_key(&v)) { + out.push(v); + } + } + out +} + +/// Collects classified categories, then finalizes into a sorted, normalized +/// [`DerivedCategorization`]. Both classification paths funnel through here so +/// de-duplication and ordering happen in exactly one place. +#[derive(Default)] +struct CategoryAccumulator { + champions: BTreeSet, + maps: BTreeSet, + tags: BTreeSet, +} + +impl CategoryAccumulator { + fn add_champion(&mut self, display_name: String) { + self.champions.insert(display_name); + } + + fn add_map(&mut self, slug: &str) { + self.maps.insert(slug.to_string()); + } + + fn add_tag(&mut self, tag: Tag) { + self.tags.insert(tag.slug().to_string()); + } + + fn remove_tag(&mut self, tag: Tag) { + self.tags.remove(tag.slug()); + } + + fn clear_maps(&mut self) { + self.maps.clear(); + } + + fn finish(self) -> DerivedCategorization { + DerivedCategorization { + champions: dedup_normalized(self.champions), + maps: dedup_normalized(self.maps), + tags: dedup_normalized(self.tags), + } + } +} + +// ─── Precise classification (modpkg chunk paths) ─── + +/// What a single chunk path resolves to. [`ChunkClass::Unclassified`] +/// contributes nothing, signalling the caller to defer to the coarse path. +enum ChunkClass { + /// Champion display name; also implies the `champion-skin` tag. + Champion(String), + /// A placeable ward (`ward-skin`). + Ward, + /// Map content (`map-skin`), with a well-known slug when the path names one. + Map(Option<&'static str>), + /// A flat tag (`emote`, `summoner-icon`, `ui`, `tft`, `companion`). + Tag(Tag), + /// Recognized as nothing. + Unclassified, +} + +/// A modpkg chunk path split into lowercased, separator-normalized segments +/// (`assets\Characters\Aatrox\...` → `["assets", "characters", "aatrox", …]`). +struct ChunkPath { + segments: Vec, +} + +impl ChunkPath { + fn parse(raw: &str) -> Self { + let segments = raw + .replace('\\', "/") + .split('/') + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()) + .collect(); + Self { segments } + } + + /// The segment immediately following the first occurrence of `key`. + fn segment_after(&self, key: &str) -> Option<&str> { + let idx = self.segments.iter().position(|s| s == key)?; + self.segments.get(idx + 1).map(String::as_str) + } + + fn has_segment(&self, segment: &str) -> bool { + self.segments.iter().any(|s| s == segment) + } + + /// Classify this path. Rules are ordered by specificity; the first match wins. + fn classify(&self, roster: &ChampionRoster) -> ChunkClass { + // 1. `characters/` — champion, ward, or an ignored summon/structure. + if let Some(name) = self.segment_after("characters") { + return match roster.lookup(name) { + Some(display) => ChunkClass::Champion(display.to_string()), + None if is_ward_entity(name) => ChunkClass::Ward, + None => ChunkClass::Unclassified, + }; + } + + // 2. Map content — `assets|data/maps/...` or `levels/mapNN/...`. + let is_map = + self.has_segment("maps") || self.segments.first().is_some_and(|s| s == "levels"); + if is_map { + return ChunkClass::Map(map_slug_from_segments(&self.segments)); + } + + // 3. Loadout cosmetics — `[assets/]loadouts//...`. + if let Some(kind) = self.segment_after("loadouts") { + return match kind { + "summoneremotes" => ChunkClass::Tag(Tag::Emote), + "companions" => ChunkClass::Tag(Tag::Companion), + k if k.starts_with("tft") => ChunkClass::Tag(Tag::Tft), + _ => ChunkClass::Unclassified, + }; + } + + // 4. UX / HUD — `assets/ux//...`. + if let Some(kind) = self.segment_after("ux") { + return match kind { + "summonericons" => ChunkClass::Tag(Tag::SummonerIcon), + k if k.starts_with("tft") => ChunkClass::Tag(Tag::Tft), + _ => ChunkClass::Tag(Tag::Ui), + }; + } + + // 5. Standalone companion / TFT content not under loadouts. + if self.has_segment("companions") { + return ChunkClass::Tag(Tag::Companion); + } + if self + .segments + .iter() + .any(|s| s == "tft" || s.starts_with("tftset")) + { + return ChunkClass::Tag(Tag::Tft); + } + + ChunkClass::Unclassified + } +} + +impl DerivedCategorization { + /// Precise classification from a modpkg's internal chunk paths. + /// + /// `roster` decides which `characters/` entities are real champions. Pure + /// over `chunk_paths`; safe to compute once at analysis time and persist. + /// + /// An empty result means "nothing to add" and tells the caller to defer to + /// the coarse WAD-footprint classification — so, unlike the coarse path, + /// this one deliberately never emits a `misc` fallback that would clobber it. + pub fn from_chunk_paths(chunk_paths: &[String], roster: &ChampionRoster) -> Self { + let mut acc = CategoryAccumulator::default(); + + for path in chunk_paths { + match ChunkPath::parse(path).classify(roster) { + ChunkClass::Champion(name) => { + acc.add_champion(name); + acc.add_tag(Tag::ChampionSkin); + } + ChunkClass::Ward => acc.add_tag(Tag::WardSkin), + ChunkClass::Map(slug) => { + acc.add_tag(Tag::MapSkin); + if let Some(slug) = slug { + acc.add_map(slug); + } + } + ChunkClass::Tag(tag) => acc.add_tag(tag), + ChunkClass::Unclassified => {} + } + } + + acc.finish() + } +} + +// ─── Coarse classification (WAD footprint) ─── + +/// One affected-WAD path split into the segments we classify on. Separators are +/// normalized (`\` → `/`) and empty segments dropped; segment matching is +/// case-insensitive while champion names preserve their original case. +struct WadPath { + segments: Vec, + /// Index of the first segment that isn't `DATA`/`FINAL` — the category root. + category_index: Option, +} + +impl WadPath { + fn parse(raw: &str) -> Self { + let segments: Vec = raw + .replace('\\', "/") + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + let category_index = segments.iter().position(|s| { + let lower = s.to_ascii_lowercase(); + lower != "data" && lower != "final" + }); + Self { + segments, + category_index, + } + } + + /// The category root segment, lowercased and with any WAD extension stripped + /// (`"UI.wad.client"` → `"ui"`, `"Champions"` → `"champions"`). + fn category(&self) -> Option { + let seg = &self.segments[self.category_index?]; + Some(seg.split('.').next().unwrap_or(seg).to_ascii_lowercase()) + } + + /// Champion internal name: the file stem of the segment after `Champions/`. + fn champion_name(&self) -> Option { + let idx = self.category_index?; + if !self.segments.get(idx)?.eq_ignore_ascii_case("champions") { + return None; + } + let file = self.segments.get(idx + 1)?; + let stem = file.split('.').next().unwrap_or(file); + (!stem.is_empty()).then(|| stem.to_string()) + } + + /// The first known map slug among this path's segments, when it is a `Maps/` + /// path (`Maps/Shipping/Map11/...` → `"summoners-rift"`). `None` for a + /// `Maps/` path whose number isn't a known map — the `map-skin` tag still + /// applies, just without a specific slug. + fn map_slug(&self) -> Option<&'static str> { + let idx = self.category_index?; + if !self.segments.get(idx)?.eq_ignore_ascii_case("maps") { + return None; + } + map_slug_from_segments(&self.segments) + } +} + +/// The coarse tag implied by a WAD category root (`"champions"` → +/// `Tag::ChampionSkin`), falling back to `Tag::Misc` for unrecognized roots. +fn coarse_tag(category: &str) -> Tag { + match category { + "champions" => Tag::ChampionSkin, + "maps" => Tag::MapSkin, + "ux" | "ui" => Tag::Ui, + "companions" => Tag::Companion, + c if c.starts_with("tft") => Tag::Tft, + _ => Tag::Misc, + } +} + +impl DerivedCategorization { + /// Coarse classification from a mod's WAD footprint. + /// + /// Shared chunks are duplicated across WADs, so the overlay distributes a + /// champion mod's overrides into map WADs too. Such spillover is a subset of + /// the champion's chunks, so `counts` lets us keep a map only when it has + /// *more* overrides than any champion WAD (a real map edit). Empty `counts` + /// (the read-time fallback) suppresses maps whenever a champion is present. + pub fn from_wad_footprint(affected_wads: &[String], counts: &HashMap) -> Self { + let mut acc = CategoryAccumulator::default(); + let mut champion_max = 0; + let mut map_max = 0; + + for wad in affected_wads { + let path = WadPath::parse(wad); + let Some(category) = path.category() else { + continue; + }; + let overrides = counts.get(wad).copied().unwrap_or(0); + + acc.add_tag(coarse_tag(&category)); + + match category.as_str() { + "champions" => { + champion_max = champion_max.max(overrides); + if let Some(name) = path.champion_name() { + acc.add_champion(champion_display_name(&name)); + } + } + "maps" => { + map_max = map_max.max(overrides); + if let Some(slug) = path.map_slug() { + acc.add_map(slug); + } + } + _ => {} + } + } + + // When a champion is present and no map WAD out-edits it, the map WADs + // are base-chunk spillover, not a real map skin — drop them. + if !acc.champions.is_empty() && champion_max >= map_max { + acc.clear_maps(); + acc.remove_tag(Tag::MapSkin); + } + + acc.finish() + } +} + +impl ModWadReport { + /// Coarse classification from this report's WAD footprint, without per-WAD + /// override counts — champion presence suppresses any map spillover. + /// + /// The read-time fallback for cache entries that predate precise + /// classification (where counts weren't persisted). At analysis time, + /// [`DerivedCategorization::from_wad_footprint`] is called directly with the + /// upstream counts instead. + pub fn derive_categorization(&self) -> DerivedCategorization { + DerivedCategorization::from_wad_footprint(&self.affected_wads, &HashMap::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(wads: &[&str]) -> ModWadReport { + ModWadReport { + mod_id: "test".to_string(), + affected_wads: wads.iter().map(|s| s.to_string()).collect(), + wad_count: wads.len() as u32, + override_count: 0, + content_fingerprint: None, + game_index_fingerprint: 0, + computed_at: "2026-01-01T00:00:00Z".to_string(), + is_stale: false, + derived: DerivedCategorization::default(), + } + } + + fn derive_coarse(wads: &[&str]) -> DerivedCategorization { + report(wads).derive_categorization() + } + + fn derive_coarse_with_counts(wads: &[&str], counts: &[(&str, u32)]) -> DerivedCategorization { + let counts: HashMap = + counts.iter().map(|(w, c)| (w.to_string(), *c)).collect(); + DerivedCategorization::from_wad_footprint(&report(wads).affected_wads, &counts) + } + + fn roster() -> ChampionRoster { + ChampionRoster::from_internal_names(["Aatrox", "Ahri", "MonkeyKing", "Smolder"]) + } + + fn derive_precise(paths: &[&str]) -> DerivedCategorization { + let paths: Vec = paths.iter().map(|s| s.to_string()).collect(); + DerivedCategorization::from_chunk_paths(&paths, &roster()) + } + + // --- Coarse (WAD-footprint) path --- + + #[test] + fn coarse_champion_skin() { + let d = derive_coarse(&["DATA/FINAL/Champions/Aatrox.wad.client"]); + assert_eq!(d.champions, vec!["Aatrox"]); + assert_eq!(d.tags, vec!["champion-skin"]); + assert!(d.maps.is_empty()); + } + + #[test] + fn coarse_champion_name_override() { + let d = derive_coarse(&["DATA/FINAL/Champions/MonkeyKing.wad.client"]); + assert_eq!(d.champions, vec!["Wukong"]); + } + + #[test] + fn coarse_known_map_slug() { + let d = derive_coarse(&["DATA/FINAL/Maps/Shipping/Map11/Base/Map11.wad.client"]); + assert_eq!(d.maps, vec!["summoners-rift"]); + assert_eq!(d.tags, vec!["map-skin"]); + assert!(d.champions.is_empty()); + } + + #[test] + fn coarse_unknown_map_emits_tag_but_no_slug() { + let d = derive_coarse(&["DATA/FINAL/Maps/Shipping/Map99/Base/Map99.wad.client"]); + assert_eq!(d.tags, vec!["map-skin"]); + assert!(d.maps.is_empty()); + } + + #[test] + fn coarse_top_level_ui_wad_maps_to_ui() { + // A real fantome HUD mod's footprint is the top-level UI.wad.client file, + // not a UX/ directory — the extension must be stripped to classify it. + let d = derive_coarse(&["DATA/FINAL/UI.wad.client"]); + assert_eq!(d.tags, vec!["ui"]); + } + + #[test] + fn coarse_companions_wad_maps_to_companion() { + let d = derive_coarse(&["DATA/FINAL/Companions.wad.client"]); + assert_eq!(d.tags, vec!["companion"]); + } + + #[test] + fn coarse_tft_wad_maps_to_tft() { + let d = derive_coarse(&["DATA/FINAL/TFTSet10.wad.client"]); + assert_eq!(d.tags, vec!["tft"]); + } + + #[test] + fn coarse_backslash_and_mixed_case() { + let d = derive_coarse(&["data\\final\\champions\\Ahri.wad.client"]); + assert_eq!(d.champions, vec!["Ahri"]); + assert_eq!(d.tags, vec!["champion-skin"]); + } + + #[test] + fn coarse_old_champions_does_not_false_positive() { + let d = derive_coarse(&["DATA/FINAL/Old_Champions/Foo.wad.client"]); + assert_eq!(d.tags, vec!["misc"]); + assert!(d.champions.is_empty()); + } + + #[test] + fn coarse_champion_suppresses_map_spillover() { + // A base-skin fantome mod's footprint includes the map WADs its base + // chunks spill into. The champion presence must suppress the false map. + let d = derive_coarse(&[ + "DATA/FINAL/Champions/Aatrox.wad.client", + "DATA/FINAL/Maps/Shipping/Map11/Base/Map11.wad.client", + "DATA/FINAL/Maps/Shipping/Map12/Base/Map12.wad.client", + ]); + assert_eq!(d.champions, vec!["Aatrox"]); + assert_eq!(d.tags, vec!["champion-skin"]); + assert!(d.maps.is_empty()); + } + + #[test] + fn coarse_base_spillover_suppressed_with_counts() { + // The champion WAD holds 40 overrides; the map holds only the 8 base + // chunks that spilled in. map_max < champion_max → suppress the map. + let d = derive_coarse_with_counts( + &[ + "DATA/FINAL/Champions/Aatrox.wad.client", + "DATA/FINAL/Maps/Shipping/Map11/Base/Map11.wad.client", + ], + &[ + ("DATA/FINAL/Champions/Aatrox.wad.client", 40), + ("DATA/FINAL/Maps/Shipping/Map11/Base/Map11.wad.client", 8), + ], + ); + assert_eq!(d.champions, vec!["Aatrox"]); + assert!(d.maps.is_empty()); + assert!(!d.tags.contains(&"map-skin".to_string())); + } + + #[test] + fn coarse_genuine_map_bundle_kept_with_counts() { + // A real champion+map bundle: the map WAD holds 50 independent overrides, + // far more than the champion's 2. map_max > champion_max → keep the map. + let d = derive_coarse_with_counts( + &[ + "DATA/FINAL/Champions/Aatrox.wad.client", + "DATA/FINAL/Maps/Shipping/Map11/Base/Map11.wad.client", + ], + &[ + ("DATA/FINAL/Champions/Aatrox.wad.client", 2), + ("DATA/FINAL/Maps/Shipping/Map11/Base/Map11.wad.client", 50), + ], + ); + assert_eq!(d.champions, vec!["Aatrox"]); + assert_eq!(d.maps, vec!["summoners-rift"]); + assert!(d.tags.contains(&"champion-skin".to_string())); + assert!(d.tags.contains(&"map-skin".to_string())); + } + + #[test] + fn coarse_empty_footprint() { + assert_eq!(derive_coarse(&[]), DerivedCategorization::default()); + } + + // --- Precise (chunk-path) path --- + + #[test] + fn precise_champion_skin_with_skin_id() { + let d = derive_precise(&[ + "assets/characters/aatrox/skins/skin01/aatrox.skn", + "data/characters/aatrox/skins/skin01.bin", + ]); + assert_eq!(d.champions, vec!["Aatrox"]); + assert_eq!(d.tags, vec!["champion-skin"]); + assert!(d.maps.is_empty()); + } + + #[test] + fn precise_champion_name_override() { + let d = derive_precise(&["assets/characters/monkeyking/skins/base/monkeyking.skn"]); + assert_eq!(d.champions, vec!["Wukong"]); + assert_eq!(d.tags, vec!["champion-skin"]); + } + + #[test] + fn precise_base_particle_is_champion_not_map() { + // The exact overlap that makes the coarse path emit a false `map-skin`: + // a base particle lives in champion + every map WAD. By chunk path it is + // unambiguously the champion. + let d = derive_precise(&[ + "assets/characters/aatrox/skins/base/particles/aatrox_base_q_smokeerode.tex", + ]); + assert_eq!(d.champions, vec!["Aatrox"]); + assert_eq!(d.tags, vec!["champion-skin"]); + assert!(d.maps.is_empty()); + } + + #[test] + fn precise_map_content() { + let d = derive_precise(&[ + "assets/maps/particles/sr/foo.tex", + "data/maps/shipping/map11/map11.bin", + "levels/map11/scripts/foo.bin", + ]); + assert_eq!(d.maps, vec!["summoners-rift"]); + assert_eq!(d.tags, vec!["map-skin"]); + assert!(d.champions.is_empty()); + } + + #[test] + fn precise_map_without_number_emits_tag_only() { + let d = derive_precise(&["assets/maps/kitpieces/foo/bar.scb"]); + assert_eq!(d.tags, vec!["map-skin"]); + assert!(d.maps.is_empty()); + } + + #[test] + fn precise_ward_skin_not_champion() { + let d = derive_precise(&[ + "assets/characters/sightward/skins/skin01/sightward.skn", + "assets/characters/jammerdevice/skins/base/jammerdevice.skn", + ]); + assert_eq!(d.tags, vec!["ward-skin"]); + assert!(d.champions.is_empty()); + } + + #[test] + fn precise_non_champion_summon_is_ignored() { + // `annietibbers` is Annie's summon, not in the roster and not a ward — + // emit nothing rather than a bogus champion. + let d = derive_precise(&["assets/characters/annietibbers/skins/base/annietibbers.skn"]); + assert!(d.is_empty()); + } + + #[test] + fn precise_emote() { + let d = derive_precise(&["assets/loadouts/summoneremotes/emote_poro/foo.dds"]); + assert_eq!(d.tags, vec!["emote"]); + } + + #[test] + fn precise_summoner_icon() { + let d = derive_precise(&["assets/ux/summonericons/icon123.dds"]); + assert_eq!(d.tags, vec!["summoner-icon"]); + } + + #[test] + fn precise_companion() { + let d = derive_precise(&["loadouts/companions/pengu/pengu.bin"]); + assert_eq!(d.tags, vec!["companion"]); + } + + #[test] + fn precise_tft() { + let d = derive_precise(&[ + "assets/loadouts/tftdamageskins/foo.dds", + "assets/ux/tft/bar.dds", + ]); + assert_eq!(d.tags, vec!["tft"]); + } + + #[test] + fn precise_generic_ux_is_ui() { + let d = derive_precise(&["assets/ux/hud/foo.dds"]); + assert_eq!(d.tags, vec!["ui"]); + } + + #[test] + fn precise_unclassified_is_empty() { + // Unrecognized content yields nothing so the caller defers to the coarse + // footprint, rather than overwriting it with a `misc` tag. + let d = derive_precise(&["assets/sounds/foo/bar.bnk"]); + assert!(d.is_empty()); + } + + #[test] + fn precise_empty_input_is_empty() { + assert!(derive_precise(&[]).is_empty()); + } + + #[test] + fn precise_multi_content_mix() { + let d = derive_precise(&[ + "assets/characters/aatrox/skins/skin01/aatrox.skn", + "assets/characters/ahri/skins/skin02/ahri.skn", + "assets/ux/summonericons/icon1.dds", + ]); + assert_eq!(d.champions, vec!["Aatrox", "Ahri"]); + assert_eq!(d.tags, vec!["champion-skin", "summoner-icon"]); + } + + #[test] + fn precise_unknown_champion_is_ignored() { + // A champion released after the roster was built isn't a false negative + // disaster — it's simply not emitted (coarse footprint still catches it). + let d = derive_precise(&["assets/characters/somenewchamp/skins/base/x.skn"]); + assert!(d.is_empty()); + } + + #[test] + fn roster_collapses_localized_variants() { + let r = ChampionRoster::from_internal_names(["Aatrox", "Aatrox", "MonkeyKing"]); + assert_eq!(r.lookup("aatrox"), Some("Aatrox")); + assert_eq!(r.lookup("MonkeyKing"), Some("Wukong")); + assert_eq!(r.lookup("nope"), None); + } + + #[test] + fn normalized_dedup_collapses_punctuation_variants() { + let mut set = BTreeSet::new(); + set.insert("Kai'Sa".to_string()); + set.insert("KaiSa".to_string()); + assert_eq!(dedup_normalized(set).len(), 1); + } +} diff --git a/src-tauri/src/mods/library.rs b/src-tauri/src/mods/library.rs index 1f1e1649..ec234c75 100644 --- a/src-tauri/src/mods/library.rs +++ b/src-tauri/src/mods/library.rs @@ -1,4 +1,4 @@ -use crate::error::{AppError, AppResult}; +use crate::error::{AppError, AppResult, MutexResultExt, Utf8PathExt}; use crate::state::Settings; use camino::Utf8PathBuf; use chrono::Utc; @@ -14,7 +14,7 @@ use super::{ BulkInstallError, BulkInstallResult, InstallProgress, InstalledMod, LibraryIndex, LibraryModEntry, ModArchiveFormat, ModLayer, ModLibrary, }; -use tauri::Emitter; +use tauri::{Emitter, Manager}; impl ModLibrary { pub fn get_installed_mods(&self, settings: &Settings) -> AppResult> { @@ -505,10 +505,7 @@ impl ModLibrary { ))); } - let utf8_archive_path = - Utf8PathBuf::from_path_buf(archive_path.clone()).map_err(|p| { - AppError::InvalidPath(format!("Non-UTF8 archive path: {}", p.display())) - })?; + let utf8_archive_path = archive_path.clone().try_into_utf8("archive path")?; let content: Box = match entry.format { ModArchiveFormat::Fantome => Box::new( @@ -540,6 +537,164 @@ impl ModLibrary { }) } + /// Read a modpkg's readable internal chunk paths (e.g. + /// `assets/characters/aatrox/skins/skin01/...`) for precise categorization. + /// + /// Returns `Ok(None)` for fantome mods — their packed WAD chunks are keyed + /// by hash, carrying no readable path — and for any modpkg with no usable + /// paths. Mounting reads only the chunk table, not chunk data. + fn modpkg_chunk_paths( + &self, + settings: &Settings, + mod_id: &str, + ) -> AppResult>> { + self.with_index(settings, |storage_dir, index| { + let entry = index + .mods + .iter() + .find(|m| m.id == mod_id) + .ok_or_else(|| AppError::ModNotFound(mod_id.to_string()))?; + + if !matches!(entry.format, ModArchiveFormat::Modpkg) { + return Ok(None); + } + + let archive_path = entry.archive_path(storage_dir); + let modpkg = Modpkg::mount_from_reader(File::open(&archive_path)?) + .map_err(|e| AppError::Other(format!("Failed to mount modpkg: {}", e)))?; + let paths: Vec = modpkg.chunk_paths.values().cloned().collect(); + Ok((!paths.is_empty()).then_some(paths)) + }) + } + + /// Upgrade a report's `derived` to precise chunk-path classification when the + /// mod is a modpkg. A no-op (keeping the coarse WAD-footprint result) for + /// fantome mods or on any failure — categorization is best-effort and must + /// never block analysis. `game_dir` is the `Game/` directory containing + /// `DATA/FINAL`, used to build the champion roster. + pub fn apply_precise_categorization( + &self, + settings: &Settings, + game_dir: &Path, + report: &mut super::ModWadReport, + ) { + let chunk_paths = match self.modpkg_chunk_paths(settings, &report.mod_id) { + Ok(Some(paths)) => paths, + Ok(None) => return, + Err(e) => { + tracing::debug!("No chunk paths for {}: {}", report.mod_id, e); + return; + } + }; + + let roster = super::ChampionRoster::from_internal_names( + crate::utils::game::read_champion_names(game_dir), + ); + let precise = super::DerivedCategorization::from_chunk_paths(&chunk_paths, &roster); + if !precise.is_empty() { + report.derived = precise; + } + } + + /// Best-effort: analyze one mod's WAD footprint and persist the report so + /// auto-categorization is available without an explicit "Analyze" click. + /// + /// Returns `None` (logged) when no League path is configured or the + /// analysis fails for any reason — categorization must never block or fail + /// an install. Used by the post-install background pass in `commands::mods`. + pub fn try_analyze_and_record( + &self, + settings: &Settings, + reports: &super::WadReportState, + mod_id: &str, + ) -> Option { + let game_dir = match crate::utils::game::resolve_game_dir(settings) { + Ok(dir) => dir, + Err(e) => { + tracing::info!("Skipping WAD analysis for {mod_id}: {e}"); + return None; + } + }; + + let (profile_dir, mut enabled_mod) = match self.build_single_mod_provider(settings, mod_id) + { + Ok(v) => v, + Err(e) => { + tracing::warn!("Could not build content provider for {mod_id}: {e}"); + return None; + } + }; + + let (Ok(utf8_game_dir), Ok(utf8_state_dir)) = ( + Utf8PathBuf::from_path_buf(game_dir), + Utf8PathBuf::from_path_buf(profile_dir), + ) else { + tracing::warn!("Non-UTF8 path while analyzing {mod_id}; skipping"); + return None; + }; + + let upstream = match ltk_overlay::OverlayBuilder::analyze_single_mod( + &utf8_game_dir, + &utf8_state_dir, + &mut enabled_mod, + ) { + Ok(u) => u, + Err(e) => { + tracing::warn!("WAD analysis failed for {mod_id}: {e}"); + return None; + } + }; + + let mut report = super::ModWadReport::from_upstream(upstream); + self.apply_precise_categorization(settings, utf8_game_dir.as_std_path(), &mut report); + match reports.0.lock().mutex_err() { + Ok(mut store) => { + if let Err(e) = store.upsert(report.clone()) { + tracing::warn!("Failed to persist WAD report for {mod_id}: {e}"); + return None; + } + } + Err(e) => { + tracing::warn!("WAD report store lock poisoned for {mod_id}: {e}"); + return None; + } + } + Some(report) + } + + /// Compute WAD footprint reports for freshly installed mods on a detached + /// background thread, then emit `wad-reports-updated` once so the UI refetches. + /// Best-effort: never blocks or fails the install — a missing League path + /// or analysis error just leaves the mod uncategorized until the user analyzes it manually. + pub fn spawn_categorization(&self, settings: &Settings, mod_ids: Vec) { + if mod_ids.is_empty() { + return; + } + + let library = self.clone(); + let settings = settings.clone(); + let app = self.app_handle().clone(); + std::thread::spawn(move || { + let Some(reports) = app.try_state::() else { + return; + }; + + let mut recorded_any = false; + for id in &mod_ids { + if library + .try_analyze_and_record(&settings, &reports, id) + .is_some() + { + recorded_any = true; + } + } + + if recorded_any { + let _ = app.emit("wad-reports-updated", ()); + } + }); + } + pub fn get_enabled_mods_for_overlay( &self, settings: &Settings, @@ -578,10 +733,7 @@ impl ModLibrary { archive_path.display() ); - let utf8_archive_path = - Utf8PathBuf::from_path_buf(archive_path.clone()).map_err(|p| { - AppError::InvalidPath(format!("Non-UTF8 archive path: {}", p.display())) - })?; + let utf8_archive_path = archive_path.clone().try_into_utf8("archive path")?; let content: Box = match entry.format { ModArchiveFormat::Fantome => Box::new( diff --git a/src-tauri/src/mods/mod.rs b/src-tauri/src/mods/mod.rs index fa334207..5178af19 100644 --- a/src-tauri/src/mods/mod.rs +++ b/src-tauri/src/mods/mod.rs @@ -1,3 +1,4 @@ +mod categorize; mod folders; mod inspect; mod library; @@ -9,6 +10,7 @@ pub(crate) mod watcher; pub use migration::*; +pub use categorize::{ChampionRoster, DerivedCategorization}; pub use inspect::{inspect_modpkg_file, ModpkgInfo}; pub use wad_reports::{ModWadReport, WadReportState}; diff --git a/src-tauri/src/mods/wad_reports.rs b/src-tauri/src/mods/wad_reports.rs index d5933457..6f16cb96 100644 --- a/src-tauri/src/mods/wad_reports.rs +++ b/src-tauri/src/mods/wad_reports.rs @@ -5,15 +5,19 @@ //! They're produced as a side effect of [`crate::overlay::ensure_overlay`] and //! on demand via the `analyze_mod_wads` Tauri command. +use super::DerivedCategorization; use crate::error::{AppResult, MutexResultExt}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use ts_rs::TS; const WAD_REPORTS_FILENAME: &str = "wad-reports.json"; -const SCHEMA_VERSION: u32 = 1; +/// v2 added the persisted `derived` categorization. Older (`v1`) caches load +/// fine — their missing `derived` defaults to empty and is recomputed coarsely +/// from `affected_wads` on read, then upgraded to precise on the next analysis. +const SCHEMA_VERSION: u32 = 2; /// Per-mod WAD footprint summary sent across the IPC boundary. /// @@ -35,18 +39,33 @@ pub struct ModWadReport { /// current values; computed on read, never persisted. #[serde(default)] pub is_stale: bool, + /// Champions / maps / tags derived from the mod's contents. Computed at + /// analysis time — precisely from a modpkg's chunk paths when available, + /// otherwise coarsely from `affected_wads` — and persisted so reads don't + /// re-open the archive. Improving the classifier takes effect on the next + /// analysis (e.g. reinstall or "Analyze uncategorized"). + #[serde(default)] + pub derived: DerivedCategorization, } impl ModWadReport { /// Construct from an upstream `ltk_overlay::ModWadReport`. Always fresh /// (`is_stale = false`); the store flips that flag on subsequent reads. pub fn from_upstream(report: ltk_overlay::ModWadReport) -> Self { - let affected_wads: Vec = report - .affected_wads - .into_iter() - .map(|p| p.into_string()) - .collect(); + // Split the upstream per-WAD entries into the path list we keep and a + // transient path→count map. The counts feed the coarse classifier's + // spillover suppression but aren't stored — only the resulting `derived` + // is persisted. + let mut affected_wads = Vec::with_capacity(report.affected_wads.len()); + let mut overrides_per_wad = HashMap::with_capacity(report.affected_wads.len()); + for wad in report.affected_wads { + let path = String::from(wad.path); + overrides_per_wad.insert(path.clone(), wad.override_count); + affected_wads.push(path); + } + let wad_count = affected_wads.len() as u32; + let derived = DerivedCategorization::from_wad_footprint(&affected_wads, &overrides_per_wad); Self { mod_id: report.mod_id, affected_wads, @@ -56,6 +75,7 @@ impl ModWadReport { game_index_fingerprint: report.game_index_fingerprint, computed_at: chrono::Utc::now().to_rfc3339(), is_stale: false, + derived, } } } @@ -76,6 +96,9 @@ struct CachedWadReport { /// Orthogonal to game-index staleness. #[serde(default)] content_stale: bool, + /// Categorization captured at analysis time. Absent on `v1` caches. + #[serde(default)] + derived: DerivedCategorization, } impl CachedWadReport { @@ -89,11 +112,12 @@ impl CachedWadReport { game_index_fingerprint: report.game_index_fingerprint, computed_at: report.computed_at, content_stale: false, + derived: report.derived, } } fn into_report(self, game_index_stale: bool) -> ModWadReport { - ModWadReport { + let mut report = ModWadReport { mod_id: self.mod_id, affected_wads: self.affected_wads, wad_count: self.wad_count, @@ -102,7 +126,14 @@ impl CachedWadReport { game_index_fingerprint: self.game_index_fingerprint, computed_at: self.computed_at, is_stale: game_index_stale || self.content_stale, + derived: self.derived, + }; + // Pre-v2 caches (and any mod analyzed before precise classification) + // carry no persisted categorization — recompute it coarsely on read. + if report.derived.is_empty() { + report.derived = report.derive_categorization(); } + report } } @@ -186,14 +217,20 @@ impl WadReportStore { } } + /// `true` when `fingerprint` differs from the most recently observed + /// game-index fingerprint. An unknown current fingerprint is treated as + /// not-stale (nothing to compare against yet). + fn game_index_stale(&self, fingerprint: u64) -> bool { + self.file + .current_game_index_fp + .is_some_and(|current| current != fingerprint) + } + /// Read a single report, deriving `is_stale` against the most recently /// observed game-index fingerprint stored alongside the cache. pub fn get(&self, mod_id: &str) -> Option { let cached = self.file.reports.get(mod_id)?.clone(); - let is_stale = match self.file.current_game_index_fp { - Some(current) => current != cached.game_index_fingerprint, - None => false, - }; + let is_stale = self.game_index_stale(cached.game_index_fingerprint); Some(cached.into_report(is_stale)) } @@ -203,11 +240,8 @@ impl WadReportStore { .reports .iter() .map(|(id, cached)| { - let game_index_stale = match self.file.current_game_index_fp { - Some(current) => current != cached.game_index_fingerprint, - None => false, - }; - (id.clone(), cached.clone().into_report(game_index_stale)) + let is_stale = self.game_index_stale(cached.game_index_fingerprint); + (id.clone(), cached.clone().into_report(is_stale)) }) .collect() } @@ -329,6 +363,7 @@ mod tests { game_index_fingerprint: gif, computed_at: "2026-04-08T00:00:00Z".to_string(), is_stale: false, + derived: DerivedCategorization::default(), } } @@ -356,6 +391,36 @@ mod tests { assert_eq!(got.mod_id, "mod-a"); assert_eq!(got.wad_count, 1); assert!(!got.is_stale); + // The sample carries no persisted `derived`, so it's recomputed coarsely + // from the cached footprint on read. + assert_eq!(got.derived.champions, vec!["Aatrox"]); + assert_eq!(got.derived.tags, vec!["champion-skin"]); + } + + #[test] + fn persisted_precise_derived_survives_round_trip() { + let dir = tempdir().unwrap(); + let mut report = sample_report("mod-a", 100); + // A precise (chunk-path) classification that the coarse footprint could + // never produce — e.g. a summoner-icon mod. It must not be clobbered by + // the coarse fallback on read, and must persist across a reload. + report.derived = DerivedCategorization { + tags: vec!["summoner-icon".to_string()], + ..Default::default() + }; + { + let mut store = WadReportStore::load(Some(dir.path())); + store.upsert(report).unwrap(); + assert_eq!( + store.get("mod-a").unwrap().derived.tags, + vec!["summoner-icon"] + ); + } + let store = WadReportStore::load(Some(dir.path())); + assert_eq!( + store.get("mod-a").unwrap().derived.tags, + vec!["summoner-icon"] + ); } #[test] diff --git a/src-tauri/src/overlay/mod.rs b/src-tauri/src/overlay/mod.rs index 63d78e14..10760072 100644 --- a/src-tauri/src/overlay/mod.rs +++ b/src-tauri/src/overlay/mod.rs @@ -1,8 +1,7 @@ -use crate::error::{AppError, AppResult}; +use crate::error::{AppError, AppResult, Utf8PathExt}; use crate::mods::{ModLibrary, WadReportState}; use crate::state::{Settings, WadBlocklistEntry}; -use camino::Utf8PathBuf; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tauri::{Emitter, Manager}; const SCRIPTS_WAD: &str = "scripts.wad.client"; @@ -43,7 +42,7 @@ impl ModLibrary { workshop_project_paths: &[PathBuf], ) -> AppResult { let storage_dir = self.storage_dir(settings)?; - let game_dir = resolve_game_dir(settings)?; + let game_dir = crate::utils::game::resolve_game_dir(settings)?; let (profile_slug, enabled_mods) = self.get_enabled_mods_for_overlay(settings)?; let profile_dir = storage_dir.join("profiles").join(profile_slug.as_str()); @@ -64,17 +63,11 @@ impl ModLibrary { enabled_ids.join(", ") ); - let utf8_game_dir = Utf8PathBuf::from_path_buf(game_dir.clone()).map_err(|p| { - AppError::Other(format!("Non-UTF-8 game directory path: {}", p.display())) - })?; - let utf8_overlay_root = Utf8PathBuf::from_path_buf(overlay_root.clone()).map_err(|p| { - AppError::Other(format!("Non-UTF-8 overlay root path: {}", p.display())) - })?; - let utf8_state_dir = Utf8PathBuf::from_path_buf(profile_dir).map_err(|p| { - AppError::Other(format!("Non-UTF-8 profile directory path: {}", p.display())) - })?; + let utf8_game_dir = game_dir.clone().try_into_utf8("game directory")?; + let utf8_overlay_root = overlay_root.clone().try_into_utf8("overlay root")?; + let utf8_state_dir = profile_dir.try_into_utf8("profile directory")?; - let available_wads = list_game_wads(&game_dir).unwrap_or_else(|e| { + let available_wads = crate::utils::game::list_game_wads(&game_dir).unwrap_or_else(|e| { tracing::warn!( "Failed to enumerate game WADs for regex expansion: {}; \ regex blocklist entries will match nothing", @@ -112,9 +105,9 @@ impl ModLibrary { let mut all_mods = Vec::new(); for project_path in workshop_project_paths { - let utf8_path = Utf8PathBuf::from_path_buf(project_path.clone()).map_err(|p| { - AppError::Other(format!("Non-UTF-8 workshop project path: {}", p.display())) - })?; + let utf8_path = project_path + .clone() + .try_into_utf8("workshop project path")?; let dir_name = project_path .file_name() .and_then(|n| n.to_str()) @@ -197,7 +190,7 @@ impl ModLibrary { /// can't break the whole patch. /// - `block_scripts_wad` and `!patch_tft` add their respective WADs. /// -/// `available_wads` should come from `list_game_wads`; pass an empty slice if +/// `available_wads` should come from `crate::utils::game::list_game_wads`; pass an empty slice if /// enumeration failed (regex entries then match nothing). pub(crate) fn resolve_blocked_wads(settings: &Settings, available_wads: &[String]) -> Vec { let mut blocked: Vec = Vec::new(); @@ -236,144 +229,9 @@ pub(crate) fn resolve_blocked_wads(settings: &Settings, available_wads: &[String blocked } -/// Enumerate every `.wad` / `.wad.client` filename under the game's `DATA` directory. -/// -/// Returns lowercased, deduplicated filenames (not paths) sorted alphabetically. -/// The WAD filename space is effectively flat from the overlay's perspective — -/// `OverlayBuilder::with_blocked_wads` matches by filename only — so we discard -/// the directory part. -pub(crate) fn list_game_wads(game_dir: &Path) -> AppResult> { - let data_dir = game_dir.join("DATA"); - if !data_dir.exists() { - return Err(AppError::ValidationFailed(format!( - "Game DATA directory does not exist: {}", - data_dir.display() - ))); - } - - let mut out: Vec = Vec::new(); - let mut stack: Vec = vec![data_dir]; - while let Some(dir) = stack.pop() { - let read = match std::fs::read_dir(&dir) { - Ok(r) => r, - Err(e) => { - tracing::warn!("Failed to read {}: {}", dir.display(), e); - continue; - } - }; - for entry in read.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - continue; - } - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - let lower = name.to_ascii_lowercase(); - if lower.ends_with(".wad") || lower.ends_with(".wad.client") { - out.push(lower); - } - } - } - - out.sort(); - out.dedup(); - Ok(out) -} - -pub(crate) fn resolve_game_dir(settings: &Settings) -> AppResult { - let league_root = settings - .league_path - .clone() - .ok_or_else(|| AppError::ValidationFailed("League path is not configured".to_string()))?; - - // Users might configure either the install root (…/League of Legends) or the Game dir (…/League of Legends/Game). - // Accept both. - let game_dir = league_root.join("Game"); - if game_dir.exists() { - return Ok(game_dir); - } - if league_root.join("DATA").exists() { - return Ok(league_root); - } - - Err(AppError::ValidationFailed(format!( - "League path does not look like an install root or a Game directory: {}", - league_root.display() - ))) -} - #[cfg(test)] mod tests { use super::*; - use assert_matches::assert_matches; - - #[test] - fn resolve_game_dir_no_league_path() { - let settings = Settings::default(); - assert_matches!( - resolve_game_dir(&settings), - Err(AppError::ValidationFailed(_)) - ); - } - - #[test] - fn resolve_game_dir_with_game_subdir() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join("Game")).unwrap(); - - let settings = Settings { - league_path: Some(dir.path().to_path_buf()), - ..Settings::default() - }; - let result = resolve_game_dir(&settings).unwrap(); - assert!(result.ends_with("Game")); - } - - #[test] - fn resolve_game_dir_with_data_dir() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join("DATA")).unwrap(); - - let settings = Settings { - league_path: Some(dir.path().to_path_buf()), - ..Settings::default() - }; - let result = resolve_game_dir(&settings).unwrap(); - assert_eq!(result, dir.path().to_path_buf()); - } - - #[test] - fn resolve_game_dir_neither_dir() { - let dir = tempfile::tempdir().unwrap(); - let settings = Settings { - league_path: Some(dir.path().to_path_buf()), - ..Settings::default() - }; - assert_matches!( - resolve_game_dir(&settings), - Err(AppError::ValidationFailed(_)) - ); - } - - #[test] - fn list_game_wads_finds_nested_wads_and_lowercases() { - let dir = tempfile::tempdir().unwrap(); - let data = dir.path().join("DATA"); - let final_dir = data.join("FINAL").join("Champions"); - std::fs::create_dir_all(&final_dir).unwrap(); - std::fs::write(final_dir.join("Aatrox.wad.client"), b"").unwrap(); - std::fs::write(final_dir.join("Ahri.wad.client"), b"").unwrap(); - std::fs::write(data.join("Shared.wad"), b"").unwrap(); - std::fs::write(final_dir.join("not-a-wad.txt"), b"").unwrap(); - - let wads = list_game_wads(dir.path()).unwrap(); - assert!(wads.contains(&"aatrox.wad.client".to_string())); - assert!(wads.contains(&"ahri.wad.client".to_string())); - assert!(wads.contains(&"shared.wad".to_string())); - assert_eq!(wads.len(), 3); - } #[test] fn resolve_blocked_wads_exact_lowercased_and_scripts_added_by_default() { @@ -454,15 +312,6 @@ mod tests { assert_eq!(result, vec!["scripts.wad.client".to_string()]); } - #[test] - fn list_game_wads_errors_when_data_missing() { - let dir = tempfile::tempdir().unwrap(); - assert_matches!( - list_game_wads(dir.path()), - Err(AppError::ValidationFailed(_)) - ); - } - #[test] fn overlay_stage_serialization() { assert_eq!( diff --git a/src-tauri/src/utils/game.rs b/src-tauri/src/utils/game.rs new file mode 100644 index 00000000..625516c7 --- /dev/null +++ b/src-tauri/src/utils/game.rs @@ -0,0 +1,204 @@ +//! Read-only utilities for resolving and inspecting a League game directory. + +use crate::error::{AppError, AppResult}; +use crate::state::Settings; +use std::path::{Path, PathBuf}; + +/// Resolve the game directory (the one containing `DATA`) from settings. +/// +/// Users may configure either the install root (`…/League of Legends`) or the +/// `Game` subdirectory directly; both are accepted. +pub(crate) fn resolve_game_dir(settings: &Settings) -> AppResult { + let league_root = settings + .league_path + .clone() + .ok_or_else(|| AppError::ValidationFailed("League path is not configured".to_string()))?; + + let game_dir = league_root.join("Game"); + if game_dir.exists() { + return Ok(game_dir); + } + if league_root.join("DATA").exists() { + return Ok(league_root); + } + + Err(AppError::ValidationFailed(format!( + "League path does not look like an install root or a Game directory: {}", + league_root.display() + ))) +} + +/// Enumerate every `.wad` / `.wad.client` filename under the game's `DATA` directory. +/// +/// Returns lowercased, deduplicated filenames (not paths) sorted alphabetically. +/// The WAD filename space is effectively flat from the overlay's perspective — +/// `OverlayBuilder::with_blocked_wads` matches by filename only — so we discard +/// the directory part. +pub(crate) fn list_game_wads(game_dir: &Path) -> AppResult> { + let data_dir = game_dir.join("DATA"); + if !data_dir.exists() { + return Err(AppError::ValidationFailed(format!( + "Game DATA directory does not exist: {}", + data_dir.display() + ))); + } + + let mut out: Vec = Vec::new(); + let mut stack: Vec = vec![data_dir]; + while let Some(dir) = stack.pop() { + let read = match std::fs::read_dir(&dir) { + Ok(r) => r, + Err(e) => { + tracing::warn!("Failed to read {}: {}", dir.display(), e); + continue; + } + }; + for entry in read.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".wad") || lower.ends_with(".wad.client") { + out.push(lower); + } + } + } + + out.sort(); + out.dedup(); + Ok(out) +} + +/// Read champion internal names (WAD stems, e.g. `"Aatrox"`, `"MonkeyKing"`) +/// from `{game_dir}/DATA/FINAL/Champions`. Returns an empty list (logged at +/// debug) when the directory can't be read — the resulting roster then matches +/// no champion and the coarse WAD footprint still applies. +pub(crate) fn read_champion_names(game_dir: &Path) -> Vec { + let champ_dir = game_dir.join("DATA").join("FINAL").join("Champions"); + let entries = match std::fs::read_dir(&champ_dir) { + Ok(entries) => entries, + Err(e) => { + tracing::debug!( + "Champion roster unavailable at {}: {}", + champ_dir.display(), + e + ); + return Vec::new(); + } + }; + entries + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + name.to_ascii_lowercase() + .ends_with(".wad.client") + .then(|| name.split('.').next().unwrap_or(name.as_str()).to_string()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + #[test] + fn resolve_game_dir_no_league_path() { + let settings = Settings::default(); + assert_matches!( + resolve_game_dir(&settings), + Err(AppError::ValidationFailed(_)) + ); + } + + #[test] + fn resolve_game_dir_with_game_subdir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("Game")).unwrap(); + + let settings = Settings { + league_path: Some(dir.path().to_path_buf()), + ..Settings::default() + }; + let result = resolve_game_dir(&settings).unwrap(); + assert!(result.ends_with("Game")); + } + + #[test] + fn resolve_game_dir_with_data_dir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("DATA")).unwrap(); + + let settings = Settings { + league_path: Some(dir.path().to_path_buf()), + ..Settings::default() + }; + let result = resolve_game_dir(&settings).unwrap(); + assert_eq!(result, dir.path().to_path_buf()); + } + + #[test] + fn resolve_game_dir_neither_dir() { + let dir = tempfile::tempdir().unwrap(); + let settings = Settings { + league_path: Some(dir.path().to_path_buf()), + ..Settings::default() + }; + assert_matches!( + resolve_game_dir(&settings), + Err(AppError::ValidationFailed(_)) + ); + } + + #[test] + fn list_game_wads_finds_nested_wads_and_lowercases() { + let dir = tempfile::tempdir().unwrap(); + let data = dir.path().join("DATA"); + let final_dir = data.join("FINAL").join("Champions"); + std::fs::create_dir_all(&final_dir).unwrap(); + std::fs::write(final_dir.join("Aatrox.wad.client"), b"").unwrap(); + std::fs::write(final_dir.join("Ahri.wad.client"), b"").unwrap(); + std::fs::write(data.join("Shared.wad"), b"").unwrap(); + std::fs::write(final_dir.join("not-a-wad.txt"), b"").unwrap(); + + let wads = list_game_wads(dir.path()).unwrap(); + assert!(wads.contains(&"aatrox.wad.client".to_string())); + assert!(wads.contains(&"ahri.wad.client".to_string())); + assert!(wads.contains(&"shared.wad".to_string())); + assert_eq!(wads.len(), 3); + } + + #[test] + fn list_game_wads_errors_when_data_missing() { + let dir = tempfile::tempdir().unwrap(); + assert_matches!( + list_game_wads(dir.path()), + Err(AppError::ValidationFailed(_)) + ); + } + + #[test] + fn read_champion_names_reads_wad_stems() { + let dir = tempfile::tempdir().unwrap(); + let champ_dir = dir.path().join("DATA").join("FINAL").join("Champions"); + std::fs::create_dir_all(&champ_dir).unwrap(); + std::fs::write(champ_dir.join("Aatrox.wad.client"), b"").unwrap(); + std::fs::write(champ_dir.join("MonkeyKing.wad.client"), b"").unwrap(); + std::fs::write(champ_dir.join("readme.txt"), b"").unwrap(); + + let mut names = read_champion_names(dir.path()); + names.sort(); + assert_eq!(names, vec!["Aatrox", "MonkeyKing"]); + } + + #[test] + fn read_champion_names_missing_dir_is_empty() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_champion_names(dir.path()).is_empty()); + } +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index ce12f2f4..4d82f5ea 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -1 +1,2 @@ +pub mod game; pub mod native; diff --git a/src/components/AutoPill.tsx b/src/components/AutoPill.tsx new file mode 100644 index 00000000..efdca6a7 --- /dev/null +++ b/src/components/AutoPill.tsx @@ -0,0 +1,48 @@ +import { Sparkles } from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +type AutoPillTone = "accent" | "emerald" | "sky"; + +const TONE_CLASSES: Record = { + accent: "border-accent-400/60 bg-accent-500/10 text-accent-300", + emerald: "border-emerald-400/60 bg-emerald-500/10 text-emerald-300", + sky: "border-sky-400/60 bg-sky-500/10 text-sky-300", +}; + +interface AutoPillProps { + label: string; + tone?: AutoPillTone; + /** When provided, the pill renders as a button (an actionable suggestion). */ + onClick?: () => void; + className?: string; +} + +/** + * A dashed-outline pill marking an auto-detected (WAD-footprint-derived) + * category. Static for display; pass `onClick` to use it as a clickable + * suggestion chip. + */ +export function AutoPill({ label, tone = "accent", onClick, className }: AutoPillProps) { + const classes = twMerge( + "inline-flex items-center gap-0.5 rounded border border-dashed px-1.5 py-0.5 text-[10px] leading-tight", + TONE_CLASSES[tone], + onClick && "cursor-pointer transition-colors hover:bg-surface-700/40", + className, + ); + + if (onClick) { + return ( + + ); + } + + return ( + + + {label} + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index fa2a29ef..b34cd554 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ export * from "./AlertBox"; +export * from "./AutoPill"; export * from "./Button"; export * from "./ButtonGroup"; export * from "./Checkbox"; diff --git a/src/lib/bindings/DerivedCategorization.ts b/src/lib/bindings/DerivedCategorization.ts new file mode 100644 index 00000000..478757aa --- /dev/null +++ b/src/lib/bindings/DerivedCategorization.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Categories derived from a mod's WAD footprint. Each list is de-duplicated and + * sorted. Champions hold internal names (e.g. `"Aatrox"`); maps and tags hold + * well-known slugs (e.g. `"summoners-rift"`, `"champion-skin"`). + */ +export type DerivedCategorization = { + champions: Array; + maps: Array; + tags: Array; +}; diff --git a/src/lib/bindings/ModWadReport.ts b/src/lib/bindings/ModWadReport.ts index 12faa3e2..ed1b0c1f 100644 --- a/src/lib/bindings/ModWadReport.ts +++ b/src/lib/bindings/ModWadReport.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DerivedCategorization } from "./DerivedCategorization"; /** * Per-mod WAD footprint summary sent across the IPC boundary. @@ -22,4 +23,10 @@ export type ModWadReport = { * current values; computed on read, never persisted. */ isStale: boolean; + /** + * Champions / maps / tags derived from `affected_wads`. Computed on read + * from the WAD footprint, never persisted — improving the classification + * tables takes effect on the next read with no re-analysis. + */ + derived: DerivedCategorization; }; diff --git a/src/lib/bindings/index.ts b/src/lib/bindings/index.ts index 9563c1ab..03cd7305 100644 --- a/src/lib/bindings/index.ts +++ b/src/lib/bindings/index.ts @@ -12,6 +12,7 @@ export type { ContentEntry } from "./ContentEntry"; export type { ContentTree } from "./ContentTree"; export type { CreateProjectArgs } from "./CreateProjectArgs"; export type { CslolModInfo } from "./CslolModInfo"; +export type { DerivedCategorization } from "./DerivedCategorization"; export type { DiagnosticReport } from "./DiagnosticReport"; export type { EditModMetadataArgs } from "./EditModMetadataArgs"; export type { ErrorCode } from "./ErrorCode"; diff --git a/src/modules/library/api/index.ts b/src/modules/library/api/index.ts index 9d32455b..5be27e23 100644 --- a/src/modules/library/api/index.ts +++ b/src/modules/library/api/index.ts @@ -1,11 +1,13 @@ export { libraryKeys } from "./keys"; export { useAnalyzeModWads } from "./useAnalyzeModWads"; +export { useAnalyzeUncategorizedMods } from "./useAnalyzeUncategorizedMods"; export { useBulkInstallMods } from "./useBulkInstallMods"; export type { BulkUninstallResult } from "./useBulkUninstallMods"; export { useBulkUninstallMods } from "./useBulkUninstallMods"; export { useCreateProfile } from "./useCreateProfile"; export { useDeleteProfile } from "./useDeleteProfile"; export { useEditMod } from "./useEditMod"; +export { useEffectiveCategories, useModEffectiveCategories } from "./useEffectiveCategories"; export { useEnableModWithLayers } from "./useEnableModWithLayers"; export { useFilteredMods } from "./useFilteredMods"; export type { FilterOptions } from "./useFilterOptions"; diff --git a/src/modules/library/api/useAnalyzeUncategorizedMods.ts b/src/modules/library/api/useAnalyzeUncategorizedMods.ts new file mode 100644 index 00000000..fe014fca --- /dev/null +++ b/src/modules/library/api/useAnalyzeUncategorizedMods.ts @@ -0,0 +1,103 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { useToast } from "@/components"; +import { api, type AppError, type InstalledMod, type ModWadReport } from "@/lib/tauri"; +import { isOk } from "@/utils/result"; + +import { libraryKeys } from "./keys"; + +interface AnalyzeFailure { + name: string; + message: string; +} + +interface AnalyzeBackfillResult { + analyzed: number; + failures: AnalyzeFailure[]; +} + +/** + * Build a compact toast description, grouping failed mods by their shared + * reason so a uniform failure (e.g. an unconfigured League path) reads as one + * line listing the affected mods rather than repeating the message per mod. + */ +function describeFailures(failures: AnalyzeFailure[]): string { + const byMessage = new Map(); + for (const { name, message } of failures) { + const names = byMessage.get(message) ?? []; + names.push(name); + byMessage.set(message, names); + } + + return Array.from(byMessage, ([message, names]) => { + const shown = names.slice(0, 3).join(", "); + const extra = names.length > 3 ? ` +${names.length - 3} more` : ""; + return `${shown}${extra}: ${message}`; + }).join(" · "); +} + +/** + * Backfill WAD-footprint reports for mods that don't have one yet — e.g. + * installed before auto-categorization existed, or while no League path was + * configured. New installs already analyze themselves in the background, so + * this is a one-shot catch-up for an existing library. + * + * Runs sequentially (the analyzer reuses the warmed game index, so concurrent + * runs would only thrash disk), patching the shared report cache as each mod + * completes. Each mod is isolated: a backend error OR an unexpected promise + * rejection is recorded as a failure and never aborts the rest of the batch. + */ +export function useAnalyzeUncategorizedMods() { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation({ + mutationFn: async (mods) => { + let analyzed = 0; + const failures: AnalyzeFailure[] = []; + + for (const mod of mods) { + try { + const result = await api.analyzeModWads(mod.id); + if (isOk(result)) { + analyzed++; + const report = result.value; + queryClient.setQueryData>( + libraryKeys.wadReports(), + (old) => ({ ...(old ?? {}), [report.modId]: report }), + ); + } else { + failures.push({ name: mod.displayName, message: result.error.message }); + } + } catch (err) { + failures.push({ + name: mod.displayName, + message: err instanceof Error ? err.message : String(err), + }); + } + } + + return { analyzed, failures }; + }, + onSuccess: ({ analyzed, failures }) => { + if (failures.length === 0) { + toast.success(`Categorized ${analyzed} mod${analyzed === 1 ? "" : "s"}`); + return; + } + if (analyzed === 0) { + toast.error( + `Couldn't categorize ${failures.length} mod${failures.length === 1 ? "" : "s"}`, + describeFailures(failures), + ); + return; + } + toast.warning( + `Categorized ${analyzed}, ${failures.length} failed`, + describeFailures(failures), + ); + }, + onError: (error) => { + toast.error("Failed to analyze mods", error.message); + }, + }); +} diff --git a/src/modules/library/api/useEffectiveCategories.ts b/src/modules/library/api/useEffectiveCategories.ts new file mode 100644 index 00000000..245cd3ed --- /dev/null +++ b/src/modules/library/api/useEffectiveCategories.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; + +import type { InstalledMod } from "@/lib/tauri"; +import { computeEffectiveCategories, type EffectiveCategories } from "@/modules/library/utils"; + +import { useAllModWadReports } from "./useModWadReport"; + +/** + * Join each mod with its (sparse) WAD-footprint report to produce its + * "effective" categories — declared metadata unioned with values derived from + * the footprint. Reads the shared batch report query, so no per-mod IPC. + * + * Every mod gets an entry; mods without a report contribute declared-only + * values. + */ +export function useEffectiveCategories(mods: InstalledMod[]): Map { + const { data: reports } = useAllModWadReports(); + + return useMemo(() => { + const map = new Map(); + for (const mod of mods) { + map.set(mod.id, computeEffectiveCategories(mod, reports?.[mod.id])); + } + return map; + }, [mods, reports]); +} + +/** + * Effective categories for a single mod. Reads the shared batch report query — + * the matching component owns its own data rather than receiving it as a prop. + */ +export function useModEffectiveCategories(mod: InstalledMod): EffectiveCategories { + const { data: reports } = useAllModWadReports(); + return useMemo(() => computeEffectiveCategories(mod, reports?.[mod.id]), [mod, reports]); +} diff --git a/src/modules/library/api/useFilterOptions.ts b/src/modules/library/api/useFilterOptions.ts index 0b2188b8..2e0799e5 100644 --- a/src/modules/library/api/useFilterOptions.ts +++ b/src/modules/library/api/useFilterOptions.ts @@ -2,6 +2,8 @@ import { useMemo } from "react"; import type { InstalledMod } from "@/lib/tauri"; +import { useEffectiveCategories } from "./useEffectiveCategories"; + export interface FilterOptions { tags: string[]; champions: string[]; @@ -9,15 +11,18 @@ export interface FilterOptions { } export function useFilterOptions(mods: InstalledMod[]): FilterOptions { + const effective = useEffectiveCategories(mods); + return useMemo(() => { const tags = new Set(); const champions = new Set(); const maps = new Set(); for (const mod of mods) { - for (const t of mod.tags) tags.add(t); - for (const c of mod.champions) champions.add(c); - for (const m of mod.maps) maps.add(m); + const eff = effective.get(mod.id); + for (const t of eff?.tags ?? mod.tags) tags.add(t); + for (const c of eff?.champions ?? mod.champions) champions.add(c); + for (const m of eff?.maps ?? mod.maps) maps.add(m); } return { @@ -25,5 +30,5 @@ export function useFilterOptions(mods: InstalledMod[]): FilterOptions { champions: [...champions].sort(), maps: [...maps].sort(), }; - }, [mods]); + }, [mods, effective]); } diff --git a/src/modules/library/api/useFilteredMods.ts b/src/modules/library/api/useFilteredMods.ts index d5fca8b8..288b5a45 100644 --- a/src/modules/library/api/useFilteredMods.ts +++ b/src/modules/library/api/useFilteredMods.ts @@ -4,9 +4,12 @@ import type { InstalledMod } from "@/lib/tauri"; import { sortMods } from "@/modules/library/utils"; import { useLibraryFilterStore } from "@/stores"; +import { useEffectiveCategories } from "./useEffectiveCategories"; + export function useFilteredMods(mods: InstalledMod[], searchQuery: string): InstalledMod[] { const { selectedTags, selectedChampions, selectedMaps, showOnlyEnabled, sort } = useLibraryFilterStore(); + const effective = useEffectiveCategories(mods); return useMemo(() => { let result = mods; @@ -22,16 +25,32 @@ export function useFilteredMods(mods: InstalledMod[], searchQuery: string): Inst result = result.filter((mod) => mod.enabled); } + // Match against declared OR footprint-derived values via effective categories. if (selectedTags.size > 0) { - result = result.filter((mod) => mod.tags.some((t) => selectedTags.has(t))); + result = result.filter((mod) => + (effective.get(mod.id)?.tags ?? mod.tags).some((t) => selectedTags.has(t)), + ); } if (selectedChampions.size > 0) { - result = result.filter((mod) => mod.champions.some((c) => selectedChampions.has(c))); + result = result.filter((mod) => + (effective.get(mod.id)?.champions ?? mod.champions).some((c) => selectedChampions.has(c)), + ); } if (selectedMaps.size > 0) { - result = result.filter((mod) => mod.maps.some((m) => selectedMaps.has(m))); + result = result.filter((mod) => + (effective.get(mod.id)?.maps ?? mod.maps).some((m) => selectedMaps.has(m)), + ); } return sortMods(result, sort); - }, [mods, searchQuery, selectedTags, selectedChampions, selectedMaps, showOnlyEnabled, sort]); + }, [ + mods, + searchQuery, + selectedTags, + selectedChampions, + selectedMaps, + showOnlyEnabled, + sort, + effective, + ]); } diff --git a/src/modules/library/components/AnalyzeUncategorizedButton.tsx b/src/modules/library/components/AnalyzeUncategorizedButton.tsx new file mode 100644 index 00000000..ef62107e --- /dev/null +++ b/src/modules/library/components/AnalyzeUncategorizedButton.tsx @@ -0,0 +1,51 @@ +import { Loader2, Sparkles } from "lucide-react"; + +import { IconButton, Tooltip } from "@/components"; +import { + useAllModWadReports, + useAnalyzeUncategorizedMods, + useInstalledMods, +} from "@/modules/library/api"; + +interface AnalyzeUncategorizedButtonProps { + /** Disable while the patcher is active or the library is still loading. */ + disabled?: boolean; +} + +/** + * Backfills WAD-footprint reports for mods that don't have one yet, so their + * auto-detected champions/maps/tags populate. Owns its own data — the parent + * only gates it on patcher/loading state. + */ +export function AnalyzeUncategorizedButton({ disabled }: AnalyzeUncategorizedButtonProps) { + const { data: allMods } = useInstalledMods(); + const { data: wadReports } = useAllModWadReports(); + const analyze = useAnalyzeUncategorizedMods(); + + const uncategorized = (allMods ?? []).filter((m) => !wadReports?.[m.id]); + const tooltip = + uncategorized.length === 0 + ? "Every mod has been categorized" + : `Detect champions, maps & tags for ${uncategorized.length} uncategorized mod${ + uncategorized.length === 1 ? "" : "s" + }`; + + return ( + + + ) : ( + + ) + } + variant="ghost" + size="sm" + onClick={() => analyze.mutate(uncategorized)} + disabled={disabled || analyze.isPending || uncategorized.length === 0} + aria-label="Analyze uncategorized mods" + /> + + ); +} diff --git a/src/modules/library/components/EditMetadataDialog.tsx b/src/modules/library/components/EditMetadataDialog.tsx index 847034ae..11d4faf0 100644 --- a/src/modules/library/components/EditMetadataDialog.tsx +++ b/src/modules/library/components/EditMetadataDialog.tsx @@ -1,14 +1,16 @@ import { useQueryClient } from "@tanstack/react-query"; import { convertFileSrc } from "@tauri-apps/api/core"; import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; -import { Edit3, Image, Trash2 } from "lucide-react"; +import { Edit3, Image, Sparkles, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -import { Button, Dialog, FormField, MultiSelect, useToast } from "@/components"; +import { AutoPill, Button, Dialog, FormField, MultiSelect, useToast } from "@/components"; import type { InstalledMod } from "@/lib/tauri"; import { libraryKeys } from "@/modules/library/api/keys"; import { useEditMod } from "@/modules/library/api/useEditMod"; +import { useModEffectiveCategories } from "@/modules/library/api/useEffectiveCategories"; import { useModThumbnail } from "@/modules/library/api/useModThumbnail"; +import { normKey } from "@/modules/library/utils/categories"; import { getMapLabel, getTagLabel, @@ -70,6 +72,62 @@ export function EditMetadataDialog({ mod, open, onOpenChange }: EditMetadataDial return options; }, [mod.maps]); + const eff = useModEffectiveCategories(mod); + + const currentChampions = useMemo( + () => + championsStr + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + [championsStr], + ); + + // Footprint-derived values not already staged in the form become suggestions. + const suggestions = useMemo(() => { + const championKeys = new Set(currentChampions.map(normKey)); + return { + tags: eff.derivedTags.filter((t) => !tags.has(t)), + maps: eff.derivedMaps.filter((m) => !maps.has(m)), + champions: eff.derivedChampions.filter((c) => !championKeys.has(normKey(c))), + }; + }, [eff, tags, maps, currentChampions]); + + const hasSuggestions = + suggestions.tags.length + suggestions.maps.length + suggestions.champions.length > 0; + + const addTag = (tag: string) => setTags((prev) => new Set(prev).add(tag)); + const addMap = (map: string) => setMaps((prev) => new Set(prev).add(map)); + const addChampion = (champion: string) => + setChampionsStr((prev) => { + const list = prev + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (list.some((c) => normKey(c) === normKey(champion))) return prev; + return [...list, champion].join(", "); + }); + + const applyAllSuggestions = () => { + if (suggestions.tags.length > 0) { + setTags((prev) => new Set([...prev, ...suggestions.tags])); + } + if (suggestions.maps.length > 0) { + setMaps((prev) => new Set([...prev, ...suggestions.maps])); + } + if (suggestions.champions.length > 0) { + setChampionsStr((prev) => { + const list = prev + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const keys = new Set(list.map(normKey)); + const additions = suggestions.champions.filter((c) => !keys.has(normKey(c))); + return [...list, ...additions].join(", "); + }); + } + }; + const handleSetThumbnail = async () => { const file = await openFileDialog({ multiple: false, @@ -209,6 +267,49 @@ export function EditMetadataDialog({ mod, open, onOpenChange }: EditMetadataDial onChange={(e) => setChampionsStr(e.target.value)} placeholder="e.g. Riven, Lee Sin" /> + + {hasSuggestions && ( +
+
+ + + Auto-detected suggestions + + +
+

+ Detected from the game files this mod patches. Click to add, then save. +

+
+ {suggestions.tags.map((tag) => ( + addTag(tag)} + /> + ))} + {suggestions.champions.map((champion) => ( + addChampion(champion)} + /> + ))} + {suggestions.maps.map((map) => ( + addMap(map)} + /> + ))} +
+
+ )} diff --git a/src/modules/library/components/LibraryToolbar.tsx b/src/modules/library/components/LibraryToolbar.tsx index 1f0b459f..fa6ac59c 100644 --- a/src/modules/library/components/LibraryToolbar.tsx +++ b/src/modules/library/components/LibraryToolbar.tsx @@ -8,6 +8,7 @@ import { useLibraryViewMode } from "@/modules/library/api"; import { useLibrarySelectionStore } from "@/stores"; import { ActiveFilterChips } from "./ActiveFilterChips"; +import { AnalyzeUncategorizedButton } from "./AnalyzeUncategorizedButton"; import { FilterPopover } from "./FilterPopover"; import { SortDropdown } from "./SortDropdown"; @@ -112,6 +113,7 @@ export function LibraryToolbar({ aria-label="Disable all visible mods" /> + {/* Select mode toggle */} diff --git a/src/modules/library/components/ModCard.tsx b/src/modules/library/components/ModCard.tsx deleted file mode 100644 index b5bb89d5..00000000 --- a/src/modules/library/components/ModCard.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { - Copy, - Edit3, - EllipsisVertical, - FolderOpen, - FolderX, - Info, - Layers, - ShieldAlert, - Trash2, -} from "lucide-react"; -import { useState } from "react"; -import { twMerge } from "tailwind-merge"; -import { match } from "ts-pattern"; - -import { Checkbox, Dialog, IconButton, Menu, Switch, Tooltip, useToast } from "@/components"; -import type { InstalledMod, ModLayer } from "@/lib/tauri"; -import { - useEnableModWithLayers, - useMoveModToFolder, - useSkinhackFlag, - useToggleMod, - useUninstallMod, -} from "@/modules/library/api"; -import { usePatcherStatus } from "@/modules/patcher"; -import { useLibrarySelectionStore } from "@/stores"; - -const ROOT_FOLDER_ID = "root"; -import { useModThumbnail } from "@/modules/library/api/useModThumbnail"; -import { getTagLabel } from "@/modules/library/utils/labels"; - -import { LayerPickerPopover } from "./LayerPickerPopover"; -import { WadCountBadge } from "./WadCountBadge"; - -interface ModCardProps { - mod: InstalledMod; - viewMode: "grid" | "list"; - onViewDetails?: (mod: InstalledMod) => void; - onEditMetadata?: (mod: InstalledMod) => void; -} - -export function ModCard({ mod, viewMode, onViewDetails, onEditMetadata }: ModCardProps) { - const { data: thumbnailUrl } = useModThumbnail(mod.id); - const toast = useToast(); - const toggleMod = useToggleMod(); - const uninstallMod = useUninstallMod(); - const enableWithLayers = useEnableModWithLayers(); - const moveModToFolder = useMoveModToFolder(); - const { data: patcherStatus } = usePatcherStatus(); - const [pickerOpen, setPickerOpen] = useState(false); - - const selectMode = useLibrarySelectionStore((s) => s.selectMode); - const isSelected = useLibrarySelectionStore((s) => s.selectedIds.has(mod.id)); - const toggleSelection = useLibrarySelectionStore((s) => s.toggle); - const selectRangeTo = useLibrarySelectionStore((s) => s.selectRangeTo); - - const { - isFlagged, - reason: skinhackReason, - infoOpen: skinhackInfoOpen, - setInfoOpen: setSkinhackInfoOpen, - } = useSkinhackFlag(mod); - const patcherRunning = patcherStatus?.running ?? false; - const disabled = isFlagged || patcherRunning; - const interactionsDisabled = disabled || selectMode; - const isInUserFolder = mod.folderId != null && mod.folderId !== ROOT_FOLDER_ID; - const isMultiLayer = mod.layers.length > 1; - - function handleToggle(modId: string, enabled: boolean) { - if (enabled && !mod.enabled && isMultiLayer) { - setPickerOpen(true); - return; - } - toggleMod.mutate( - { modId, enabled }, - { onError: (error) => console.error("Failed to toggle mod:", error.message) }, - ); - } - - function handlePickerConfirm(layerStates: Record) { - enableWithLayers.mutate( - { modId: mod.id, layerStates }, - { onError: (error) => console.error("Failed to enable mod with layers:", error.message) }, - ); - } - - function handlePickerCancel() { - setPickerOpen(false); - } - - function handleUninstall() { - uninstallMod.mutate(mod.id, { - onError: (error) => console.error("Failed to uninstall mod:", error.message), - }); - } - - async function handleCopyId() { - await navigator.clipboard.writeText(mod.id); - toast.success("Copied mod ID to clipboard"); - } - - async function handleOpenLocation() { - try { - await invoke("reveal_in_explorer", { path: mod.modDir }); - } catch (error) { - console.error("Failed to open location:", error); - } - } - - function handleCardClick(e: React.MouseEvent) { - if ((e.target as HTMLElement).closest("[data-no-toggle]")) { - return; - } - if (selectMode) { - if (e.shiftKey) selectRangeTo(mod.id); - else toggleSelection(mod.id); - return; - } - if (disabled) return; - handleToggle(mod.id, !mod.enabled); - } - - const inSelectedState = selectMode && isSelected; - const inEnabledState = mod.enabled && !isFlagged; - const isInteractive = !isFlagged && (selectMode || !disabled); - - const cursorClass = match({ isFlagged, isInteractive }) - .with({ isFlagged: true }, () => "cursor-default opacity-50") - .with({ isInteractive: true }, () => "cursor-pointer") - .otherwise(() => "cursor-default"); - - if (viewMode === "list") { - const stateClass = match({ isSelected: inSelectedState, isEnabled: inEnabledState }) - .with( - { isSelected: true }, - () => "border-accent-500 bg-surface-800 ring-2 ring-accent-400/60", - ) - .with( - { isEnabled: true }, - () => - "border-accent-500/40 bg-surface-800 shadow-[0_0_15px_-3px] shadow-accent-500/30 hover:-translate-y-px", - ) - .otherwise( - () => - "border-surface-700 bg-surface-900 hover:-translate-y-px hover:border-surface-600 hover:bg-surface-800/80 hover:shadow-md", - ); - - return ( -
- {selectMode && ( -
- -
- )} -
- {thumbnailUrl ? ( - - ) : ( -
- - {mod.displayName.charAt(0).toUpperCase()} - -
- )} -
- -
-
-

{mod.displayName}

- {isFlagged && ( - - - - )} -
-
-

- v{mod.version} • {mod.authors.join(", ") || "Unknown author"} -

- - {isMultiLayer && } - e.stopPropagation()}> - - -
-
- -
e.stopPropagation()}> - {isMultiLayer && !mod.enabled ? ( - - ) : ( - handleToggle(mod.id, checked)} - /> - )} -
- -
e.stopPropagation()}> - - } - variant="ghost" - size="md" - disabled={interactionsDisabled} - /> - } - /> - - - - {isFlagged && ( - } - onClick={() => setSkinhackInfoOpen(true)} - > - What is a skinhack? - - )} - {!isFlagged && ( - } - onClick={() => onViewDetails?.(mod)} - > - View Details - - )} - {!isFlagged && ( - } - onClick={() => onEditMetadata?.(mod)} - > - Edit Metadata - - )} - } onClick={handleOpenLocation}> - Open Location - - } onClick={handleCopyId}> - Copy ID - - {isInUserFolder && ( - } - onClick={() => - moveModToFolder.mutate({ modId: mod.id, folderId: ROOT_FOLDER_ID }) - } - > - Remove from folder - - )} - - } - variant="danger" - disabled={interactionsDisabled} - onClick={handleUninstall} - > - Uninstall - - - - - -
- -
- ); - } - - const stateClass = match({ isSelected: inSelectedState, isEnabled: inEnabledState }) - .with({ isSelected: true }, () => "border-accent-500 bg-surface-800 ring-2 ring-accent-400/60") - .with( - { isEnabled: true }, - () => - "border-accent-500/40 bg-surface-800 shadow-[0_0_20px_-5px] shadow-accent-500/40 hover:-translate-y-px hover:shadow-[0_0_20px_-3px,0_4px_6px_-1px] hover:shadow-accent-500/40", - ) - .otherwise( - () => - "border-surface-600 bg-surface-800 hover:-translate-y-px hover:border-surface-400 hover:bg-surface-700/80 hover:shadow-md", - ); - - return ( -
- {selectMode && ( -
- -
- )} -
e.stopPropagation()} - > - {isMultiLayer && !mod.enabled ? ( - - ) : ( - handleToggle(mod.id, checked)} - className="shadow-lg data-[unchecked]:bg-surface-600/80 data-[unchecked]:backdrop-blur-sm" - /> - )} -
- - {isFlagged && ( - -
- -
-
- )} - -
- {thumbnailUrl ? ( - - ) : ( -
- - {mod.displayName.charAt(0).toUpperCase()} - -
- )} -
- -
-
-

{mod.displayName}

- {isFlagged && } -
- -
- - {isMultiLayer && } - e.stopPropagation()}> - - -
- -
- v{mod.version} - - - {mod.authors.length > 0 ? mod.authors[0] : "Unknown"} - -
e.stopPropagation()}> - - } - variant="ghost" - size="md" - disabled={interactionsDisabled} - /> - } - /> - - - - {isFlagged && ( - } - onClick={() => setSkinhackInfoOpen(true)} - > - What is a skinhack? - - )} - {!isFlagged && ( - } - onClick={() => onViewDetails?.(mod)} - > - View Details - - )} - {!isFlagged && ( - } - onClick={() => onEditMetadata?.(mod)} - > - Edit Metadata - - )} - } - onClick={handleOpenLocation} - > - Open Location - - } onClick={handleCopyId}> - Copy ID - - {isInUserFolder && ( - } - onClick={() => - moveModToFolder.mutate({ modId: mod.id, folderId: ROOT_FOLDER_ID }) - } - > - Remove from folder - - )} - - } - variant="danger" - disabled={interactionsDisabled} - onClick={handleUninstall} - > - Uninstall - - - - - -
-
-
- -
- ); -} - -function SkinhackInfoDialog({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - return ( - - - - - - What is a skinhack? - - - -

- A skinhack is a mod that grants access to paid League of Legends skins. -

-

- Using skinhacks violates the distribution policy and can put your account at risk. LTK - Manager blocks these mods to protect both users and the modding community. -

-

- If you believe this mod was flagged incorrectly, open an issue on the GitHub - repository page with the relevant info and we will investigate. -

-
-
-
-
- ); -} - -function ModPills({ mod, max, className }: { mod: InstalledMod; max: number; className?: string }) { - const pills = [ - ...mod.tags.map((t) => ({ label: getTagLabel(t), color: "brand" as const })), - ...mod.champions.map((c) => ({ label: c, color: "emerald" as const })), - ]; - if (pills.length === 0) return null; - - const visible = pills.slice(0, max); - const overflow = pills.length - max; - - const colorClasses = { - brand: "bg-accent-500/15 text-accent-400", - emerald: "bg-emerald-500/15 text-emerald-400", - } as const; - - return ( -
- {visible.map((pill) => ( - - {pill.label} - - ))} - {overflow > 0 && +{overflow}} -
- ); -} - -function LayerBadge({ layers }: { layers: ModLayer[] }) { - const enabledCount = layers.filter((l) => l.enabled).length; - const allEnabled = enabledCount === layers.length; - - return ( - - - {allEnabled ? layers.length : `${enabledCount}/${layers.length}`} - - ); -} diff --git a/src/modules/library/components/ModCard/ModCardGrid.tsx b/src/modules/library/components/ModCard/ModCardGrid.tsx new file mode 100644 index 00000000..73434bcb --- /dev/null +++ b/src/modules/library/components/ModCard/ModCardGrid.tsx @@ -0,0 +1,113 @@ +import { ShieldAlert } from "lucide-react"; +import { twMerge } from "tailwind-merge"; +import { match } from "ts-pattern"; + +import { Checkbox, Tooltip } from "@/components"; + +import { WadCountBadge } from "../WadCountBadge"; +import { + LayerBadge, + ModCardMenu, + ModCardThumbnail, + ModCardToggle, + ModPills, + SkinhackInfoDialog, +} from "./ModCardParts"; +import type { ModCardView } from "./useModCardController"; + +export function ModCardGrid({ view }: { view: ModCardView }) { + const { + mod, + thumbnailUrl, + isFlagged, + skinhackReason, + isMultiLayer, + selectMode, + isSelected, + inSelectedState, + inEnabledState, + cursorClass, + skinhackInfoOpen, + setSkinhackInfoOpen, + onCardClick, + } = view; + + const stateClass = match({ isSelected: inSelectedState, isEnabled: inEnabledState }) + .with({ isSelected: true }, () => "border-accent-500 bg-surface-800 ring-2 ring-accent-400/60") + .with( + { isEnabled: true }, + () => + "border-accent-500/40 bg-surface-800 shadow-[0_0_20px_-5px] shadow-accent-500/40 hover:-translate-y-px hover:shadow-[0_0_20px_-3px,0_4px_6px_-1px] hover:shadow-accent-500/40", + ) + .otherwise( + () => + "border-surface-600 bg-surface-800 hover:-translate-y-px hover:border-surface-400 hover:bg-surface-700/80 hover:shadow-md", + ); + + return ( +
+ {selectMode && ( +
+ +
+ )} +
e.stopPropagation()} + > + +
+ + {isFlagged && ( + +
+ +
+
+ )} + + + +
+
+

{mod.displayName}

+ {isFlagged && } +
+ +
+ + {isMultiLayer && } + e.stopPropagation()}> + + +
+ +
+ v{mod.version} + + + {mod.authors.length > 0 ? mod.authors[0] : "Unknown"} + +
e.stopPropagation()}> + +
+
+
+ +
+ ); +} diff --git a/src/modules/library/components/ModCard/ModCardList.tsx b/src/modules/library/components/ModCard/ModCardList.tsx new file mode 100644 index 00000000..588633ba --- /dev/null +++ b/src/modules/library/components/ModCard/ModCardList.tsx @@ -0,0 +1,99 @@ +import { ShieldAlert } from "lucide-react"; +import { twMerge } from "tailwind-merge"; +import { match } from "ts-pattern"; + +import { Checkbox, Tooltip } from "@/components"; + +import { WadCountBadge } from "../WadCountBadge"; +import { + LayerBadge, + ModCardMenu, + ModCardThumbnail, + ModCardToggle, + ModPills, + SkinhackInfoDialog, +} from "./ModCardParts"; +import type { ModCardView } from "./useModCardController"; + +export function ModCardList({ view }: { view: ModCardView }) { + const { + mod, + thumbnailUrl, + isFlagged, + skinhackReason, + isMultiLayer, + selectMode, + isSelected, + inSelectedState, + inEnabledState, + cursorClass, + skinhackInfoOpen, + setSkinhackInfoOpen, + onCardClick, + } = view; + + const stateClass = match({ isSelected: inSelectedState, isEnabled: inEnabledState }) + .with({ isSelected: true }, () => "border-accent-500 bg-surface-800 ring-2 ring-accent-400/60") + .with( + { isEnabled: true }, + () => + "border-accent-500/40 bg-surface-800 shadow-[0_0_15px_-3px] shadow-accent-500/30 hover:-translate-y-px", + ) + .otherwise( + () => + "border-surface-700 bg-surface-900 hover:-translate-y-px hover:border-surface-600 hover:bg-surface-800/80 hover:shadow-md", + ); + + return ( +
+ {selectMode && ( +
+ +
+ )} + + +
+
+

{mod.displayName}

+ {isFlagged && ( + + + + )} +
+
+

+ v{mod.version} • {mod.authors.join(", ") || "Unknown author"} +

+ + {isMultiLayer && } + e.stopPropagation()}> + + +
+
+ +
e.stopPropagation()}> + +
+ +
e.stopPropagation()}> + +
+ +
+ ); +} diff --git a/src/modules/library/components/ModCard/ModCardParts.tsx b/src/modules/library/components/ModCard/ModCardParts.tsx new file mode 100644 index 00000000..3e1621e5 --- /dev/null +++ b/src/modules/library/components/ModCard/ModCardParts.tsx @@ -0,0 +1,295 @@ +import { + Copy, + Edit3, + EllipsisVertical, + FolderOpen, + FolderX, + Info, + Layers, + ShieldAlert, + Trash2, +} from "lucide-react"; + +import { AutoPill, Dialog, IconButton, Menu, Switch, Tooltip } from "@/components"; +import type { InstalledMod, ModLayer } from "@/lib/tauri"; +import { useModEffectiveCategories } from "@/modules/library/api"; +import { getMapLabel, getTagLabel } from "@/modules/library/utils/labels"; + +import { LayerPickerPopover } from "../LayerPickerPopover"; +import type { ModCardView } from "./useModCardController"; + +type CardVariant = "grid" | "list"; + +const THUMBNAIL_VARIANTS: Record = { + grid: { + container: + "relative aspect-video overflow-hidden rounded-t-xl bg-linear-to-br from-surface-700 to-surface-800", + placeholder: "text-4xl font-bold text-surface-400", + }, + list: { + container: + "relative h-12 w-[5.25rem] shrink-0 overflow-hidden rounded-lg bg-linear-to-br from-surface-700 to-surface-800", + placeholder: "text-lg font-bold text-surface-500", + }, +}; + +export function ModCardThumbnail({ + variant, + thumbnailUrl, + displayName, +}: { + variant: CardVariant; + thumbnailUrl?: string; + displayName: string; +}) { + const styles = THUMBNAIL_VARIANTS[variant]; + return ( +
+ {thumbnailUrl && ( + + )} + {!thumbnailUrl && ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} +
+ ); +} + +const GRID_SWITCH_CLASS = + "shadow-lg data-[unchecked]:bg-surface-600/80 data-[unchecked]:backdrop-blur-sm"; + +export function ModCardToggle({ variant, view }: { variant: CardVariant; view: ModCardView }) { + const { mod } = view; + const isGrid = variant === "grid"; + const switchSize = isGrid ? "sm" : undefined; + const switchClassName = isGrid ? GRID_SWITCH_CLASS : undefined; + + if (view.isMultiLayer && !mod.enabled) { + return ( + + ); + } + + return ( + view.onToggle(mod.id, checked)} + className={switchClassName} + /> + ); +} + +export function ModCardMenu({ view }: { view: ModCardView }) { + const { mod, interactionsDisabled, isFlagged, isInUserFolder } = view; + + return ( + + } + variant="ghost" + size="md" + disabled={interactionsDisabled} + /> + } + /> + + + + {isFlagged && ( + } + onClick={() => view.setSkinhackInfoOpen(true)} + > + What is a skinhack? + + )} + {!isFlagged && ( + } + onClick={() => view.onViewDetails?.(mod)} + > + View Details + + )} + {!isFlagged && ( + } + onClick={() => view.onEditMetadata?.(mod)} + > + Edit Metadata + + )} + } onClick={view.onOpenLocation}> + Open Location + + } onClick={view.onCopyId}> + Copy ID + + {isInUserFolder && ( + } onClick={view.onRemoveFromFolder}> + Remove from folder + + )} + + } + variant="danger" + disabled={interactionsDisabled} + onClick={view.onUninstall} + > + Uninstall + + + + + + ); +} + +const DECLARED_PILL_CLASSES = { + accent: "bg-accent-500/15 text-accent-400", + emerald: "bg-emerald-500/15 text-emerald-400", +} as const; + +interface DeclaredPill { + label: string; + tone: keyof typeof DECLARED_PILL_CLASSES; + key: string; +} + +interface AutoPillItem { + label: string; + tone: "accent" | "emerald" | "sky"; + key: string; +} + +export function ModPills({ + mod, + max, + className, +}: { + mod: InstalledMod; + max: number; + className?: string; +}) { + const eff = useModEffectiveCategories(mod); + + const declared: DeclaredPill[] = [ + ...mod.tags.map((t) => ({ label: getTagLabel(t), tone: "accent" as const, key: `tag:${t}` })), + ...mod.champions.map((c) => ({ label: c, tone: "emerald" as const, key: `champ:${c}` })), + ]; + const auto: AutoPillItem[] = [ + ...eff.derivedTags.map((t) => ({ + label: getTagLabel(t), + tone: "accent" as const, + key: `auto-tag:${t}`, + })), + ...eff.derivedChampions.map((c) => ({ + label: c, + tone: "emerald" as const, + key: `auto-champ:${c}`, + })), + ...eff.derivedMaps.map((m) => ({ + label: getMapLabel(m), + tone: "sky" as const, + key: `auto-map:${m}`, + })), + ]; + + const total = declared.length + auto.length; + if (total === 0) return null; + + // Declared pills get first claim on the budget so they never collapse before + // the lower-confidence auto pills. + const declaredVisible = declared.slice(0, max); + const autoVisible = auto.slice(0, Math.max(0, max - declaredVisible.length)); + const overflow = total - declaredVisible.length - autoVisible.length; + + return ( +
+ {declaredVisible.map((pill) => ( + + {pill.label} + + ))} + {autoVisible.length > 0 && ( + + + {autoVisible.map((pill) => ( + + ))} + + + )} + {overflow > 0 && +{overflow}} +
+ ); +} + +export function LayerBadge({ layers }: { layers: ModLayer[] }) { + const enabledCount = layers.filter((l) => l.enabled).length; + const allEnabled = enabledCount === layers.length; + + return ( + + + {allEnabled ? layers.length : `${enabledCount}/${layers.length}`} + + ); +} + +export function SkinhackInfoDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + + + What is a skinhack? + + + +

+ A skinhack is a mod that grants access to paid League of Legends skins. +

+

+ Using skinhacks violates the distribution policy and can put your account at risk. LTK + Manager blocks these mods to protect both users and the modding community. +

+

+ If you believe this mod was flagged incorrectly, open an issue on the GitHub + repository page with the relevant info and we will investigate. +

+
+
+
+
+ ); +} diff --git a/src/modules/library/components/ModCard/index.tsx b/src/modules/library/components/ModCard/index.tsx new file mode 100644 index 00000000..de593620 --- /dev/null +++ b/src/modules/library/components/ModCard/index.tsx @@ -0,0 +1,10 @@ +import { ModCardGrid } from "./ModCardGrid"; +import { ModCardList } from "./ModCardList"; +import { type ModCardProps, useModCardController } from "./useModCardController"; + +export function ModCard(props: ModCardProps) { + const view = useModCardController(props); + + if (props.viewMode === "list") return ; + return ; +} diff --git a/src/modules/library/components/ModCard/useModCardController.ts b/src/modules/library/components/ModCard/useModCardController.ts new file mode 100644 index 00000000..545d5d40 --- /dev/null +++ b/src/modules/library/components/ModCard/useModCardController.ts @@ -0,0 +1,195 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useState } from "react"; +import { match } from "ts-pattern"; + +import { useToast } from "@/components"; +import type { InstalledMod } from "@/lib/tauri"; +import { + useEnableModWithLayers, + useMoveModToFolder, + useSkinhackFlag, + useToggleMod, + useUninstallMod, +} from "@/modules/library/api"; +import { useModThumbnail } from "@/modules/library/api/useModThumbnail"; +import { usePatcherStatus } from "@/modules/patcher"; +import { useLibrarySelectionStore } from "@/stores"; + +const ROOT_FOLDER_ID = "root"; + +export interface ModCardProps { + mod: InstalledMod; + viewMode: "grid" | "list"; + onViewDetails?: (mod: InstalledMod) => void; + onEditMetadata?: (mod: InstalledMod) => void; +} + +/** + * View-model returned by {@link useModCardController}. Holds the derived display + * flags, the UI state shared across the card root, toggle, menu, and dialog, and + * the bound action handlers. The grid/list layouts and their leaf parts render + * purely off this object. + */ +export interface ModCardView { + mod: InstalledMod; + thumbnailUrl: string | undefined; + isFlagged: boolean; + skinhackReason: string; + disabled: boolean; + interactionsDisabled: boolean; + isInUserFolder: boolean; + isMultiLayer: boolean; + selectMode: boolean; + isSelected: boolean; + inSelectedState: boolean; + inEnabledState: boolean; + cursorClass: string; + pickerOpen: boolean; + setPickerOpen: (open: boolean) => void; + skinhackInfoOpen: boolean; + setSkinhackInfoOpen: (open: boolean) => void; + onCardClick: (e: React.MouseEvent) => void; + onToggle: (modId: string, enabled: boolean) => void; + onPickerConfirm: (layerStates: Record) => void; + onPickerCancel: () => void; + onUninstall: () => void; + onCopyId: () => void; + onOpenLocation: () => void; + onRemoveFromFolder: () => void; + onViewDetails?: (mod: InstalledMod) => void; + onEditMetadata?: (mod: InstalledMod) => void; +} + +/** + * Owns all of a mod card's interaction logic and the UI state that must be shared + * between the card body, toggle control, context menu, and skinhack dialog + */ +export function useModCardController({ + mod, + onViewDetails, + onEditMetadata, +}: ModCardProps): ModCardView { + const { data: thumbnailUrl } = useModThumbnail(mod.id); + const toast = useToast(); + const toggleMod = useToggleMod(); + const uninstallMod = useUninstallMod(); + const enableWithLayers = useEnableModWithLayers(); + const moveModToFolder = useMoveModToFolder(); + const { data: patcherStatus } = usePatcherStatus(); + const [pickerOpen, setPickerOpen] = useState(false); + + const selectMode = useLibrarySelectionStore((s) => s.selectMode); + const isSelected = useLibrarySelectionStore((s) => s.selectedIds.has(mod.id)); + const toggleSelection = useLibrarySelectionStore((s) => s.toggle); + const selectRangeTo = useLibrarySelectionStore((s) => s.selectRangeTo); + + const { + isFlagged, + reason: skinhackReason, + infoOpen: skinhackInfoOpen, + setInfoOpen: setSkinhackInfoOpen, + } = useSkinhackFlag(mod); + + const patcherRunning = patcherStatus?.running ?? false; + const disabled = isFlagged || patcherRunning; + const interactionsDisabled = disabled || selectMode; + const isInUserFolder = mod.folderId != null && mod.folderId !== ROOT_FOLDER_ID; + const isMultiLayer = mod.layers.length > 1; + + function handleToggle(modId: string, enabled: boolean) { + if (enabled && !mod.enabled && isMultiLayer) { + setPickerOpen(true); + return; + } + toggleMod.mutate( + { modId, enabled }, + { onError: (error) => console.error("Failed to toggle mod:", error.message) }, + ); + } + + function handlePickerConfirm(layerStates: Record) { + enableWithLayers.mutate( + { modId: mod.id, layerStates }, + { onError: (error) => console.error("Failed to enable mod with layers:", error.message) }, + ); + } + + function handlePickerCancel() { + setPickerOpen(false); + } + + function handleUninstall() { + uninstallMod.mutate(mod.id, { + onError: (error) => console.error("Failed to uninstall mod:", error.message), + }); + } + + async function handleCopyId() { + await navigator.clipboard.writeText(mod.id); + toast.success("Copied mod ID to clipboard"); + } + + async function handleOpenLocation() { + try { + await invoke("reveal_in_explorer", { path: mod.modDir }); + } catch (error) { + console.error("Failed to open location:", error); + } + } + + function handleRemoveFromFolder() { + moveModToFolder.mutate({ modId: mod.id, folderId: ROOT_FOLDER_ID }); + } + + function handleCardClick(e: React.MouseEvent) { + if ((e.target as HTMLElement).closest("[data-no-toggle]")) { + return; + } + if (selectMode) { + if (e.shiftKey) selectRangeTo(mod.id); + else toggleSelection(mod.id); + return; + } + if (disabled) return; + handleToggle(mod.id, !mod.enabled); + } + + const inSelectedState = selectMode && isSelected; + const inEnabledState = mod.enabled && !isFlagged; + const isInteractive = !isFlagged && (selectMode || !disabled); + + const cursorClass = match({ isFlagged, isInteractive }) + .with({ isFlagged: true }, () => "cursor-default opacity-50") + .with({ isInteractive: true }, () => "cursor-pointer") + .otherwise(() => "cursor-default"); + + return { + mod, + thumbnailUrl, + isFlagged, + skinhackReason, + disabled, + interactionsDisabled, + isInUserFolder, + isMultiLayer, + selectMode, + isSelected, + inSelectedState, + inEnabledState, + cursorClass, + pickerOpen, + setPickerOpen, + skinhackInfoOpen, + setSkinhackInfoOpen, + onCardClick: handleCardClick, + onToggle: handleToggle, + onPickerConfirm: handlePickerConfirm, + onPickerCancel: handlePickerCancel, + onUninstall: handleUninstall, + onCopyId: handleCopyId, + onOpenLocation: handleOpenLocation, + onRemoveFromFolder: handleRemoveFromFolder, + onViewDetails, + onEditMetadata, + }; +} diff --git a/src/modules/library/components/index.ts b/src/modules/library/components/index.ts index c6305a29..8493e9ab 100644 --- a/src/modules/library/components/index.ts +++ b/src/modules/library/components/index.ts @@ -1,4 +1,5 @@ export * from "./ActiveFilterChips"; +export * from "./AnalyzeUncategorizedButton"; export * from "./BulkInstallProgress"; export * from "./BulkInstallResults"; export * from "./BulkUninstallDialog"; diff --git a/src/modules/library/utils/categories.ts b/src/modules/library/utils/categories.ts new file mode 100644 index 00000000..709decbe --- /dev/null +++ b/src/modules/library/utils/categories.ts @@ -0,0 +1,72 @@ +import type { InstalledMod, ModWadReport } from "@/lib/tauri"; + +/** + * A mod's filterable categories: declared metadata unioned with values derived + * from its WAD footprint. The `derived*` lists hold only the derived values that + * are NOT already declared — they drive the "auto" pills and edit suggestions. + */ +export interface EffectiveCategories { + tags: string[]; + champions: string[]; + maps: string[]; + derivedTags: string[]; + derivedChampions: string[]; + derivedMaps: string[]; +} + +/** + * Normalization key for de-duplicating a derived value against a declared one: + * lowercase, alphanumerics only. Mirrors the backend so a derived `"Aatrox"` + * collapses against a user-typed `"aatrox"`. + */ +export function normKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +/** + * Union declared values with derived ones. Declared values win — their casing + * is preserved and they never appear in `derivedOnly`. Derived values absent + * from the declared set (by normalized key) are appended and collected into + * `derivedOnly`. + */ +function mergeCategory( + declared: string[], + derived: readonly string[] | undefined, +): { merged: string[]; derivedOnly: string[] } { + const seen = new Set(declared.map(normKey)); + const merged = [...declared]; + const derivedOnly: string[] = []; + + for (const raw of derived ?? []) { + const value = raw.trim(); + const key = normKey(value); + if (!key || seen.has(key)) continue; + seen.add(key); + merged.push(value); + derivedOnly.push(value); + } + + return { merged, derivedOnly }; +} + +/** + * Compute a mod's effective categories from its declared metadata and its + * (possibly missing) WAD-footprint report. + */ +export function computeEffectiveCategories( + mod: InstalledMod, + report: ModWadReport | null | undefined, +): EffectiveCategories { + const tags = mergeCategory(mod.tags, report?.derived.tags); + const champions = mergeCategory(mod.champions, report?.derived.champions); + const maps = mergeCategory(mod.maps, report?.derived.maps); + + return { + tags: tags.merged, + champions: champions.merged, + maps: maps.merged, + derivedTags: tags.derivedOnly, + derivedChampions: champions.derivedOnly, + derivedMaps: maps.derivedOnly, + }; +} diff --git a/src/modules/library/utils/index.ts b/src/modules/library/utils/index.ts index 225e5441..834a4958 100644 --- a/src/modules/library/utils/index.ts +++ b/src/modules/library/utils/index.ts @@ -1,3 +1,4 @@ +export * from "./categories"; export * from "./dnd"; export * from "./folders"; export * from "./labels"; diff --git a/src/modules/library/utils/labels.ts b/src/modules/library/utils/labels.ts index 54855b77..c56467f6 100644 --- a/src/modules/library/utils/labels.ts +++ b/src/modules/library/utils/labels.ts @@ -4,6 +4,9 @@ export const WELL_KNOWN_TAGS = [ "champion-skin", "map-skin", "ward-skin", + "emote", + "summoner-icon", + "companion", "ui", "hud", "font", @@ -23,6 +26,9 @@ const TAG_LABELS: Record = { "champion-skin": "Champion Skin", "map-skin": "Map Skin", "ward-skin": "Ward Skin", + emote: "Emote", + "summoner-icon": "Summoner Icon", + companion: "Companion", ui: "UI", hud: "HUD", font: "Font",