From 0240ab9c50855e49233caf68b6c83128239f48a8 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Tue, 1 Apr 2025 01:37:55 -0400 Subject: [PATCH] Enhance app version management and improve plugin list filtering with advanced search capabilities --- src-tauri/capabilities/default.json | 1 + src-tauri/src/commands/mod.rs | 3 +- src-tauri/src/commands/plugin_commands.rs | 43 +- src-tauri/src/commands/util_commands.rs | 17 + src-tauri/src/crawlers/github.rs | 94 +- src-tauri/src/crawlers/hangar.rs | 15 +- src-tauri/src/crawlers/modrinth.rs | 4 + src-tauri/src/crawlers/spigotmc.rs | 2 + src-tauri/src/lib.rs | 263 +++++- src-tauri/src/models/plugin.rs | 12 + src-tauri/src/models/repository.rs | 2 + .../src/services/plugin_scanner/scanner.rs | 2 + .../services/update_manager/update_checker.rs | 857 ++++++++++++++++-- .../services/update_manager/version_utils.rs | 310 ++++++- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 40 +- .../plugins/PluginList/PluginList.tsx | 250 +++-- .../server/ServerInfo/ServerInfo.tsx | 29 +- src/context/PluginContext/PluginContext.tsx | 1 + 19 files changed, 1771 insertions(+), 176 deletions(-) create mode 100644 src-tauri/src/commands/util_commands.rs diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0bd3d6a..f8b4479 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,7 @@ { "identifier": "dialog:allow-open" }, { "identifier": "shell:default" }, { "identifier": "shell:allow-open" }, + { "identifier": "shell:allow-execute" }, { "identifier": "fs:default" }, { "identifier": "fs:allow-read-dir", diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 72ca321..612a6d0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,2 +1,3 @@ pub mod plugin_commands; -pub mod scan_commands; \ No newline at end of file +pub mod scan_commands; +pub mod util_commands; \ No newline at end of file diff --git a/src-tauri/src/commands/plugin_commands.rs b/src-tauri/src/commands/plugin_commands.rs index ead4b3e..d38b761 100644 --- a/src-tauri/src/commands/plugin_commands.rs +++ b/src-tauri/src/commands/plugin_commands.rs @@ -129,7 +129,8 @@ pub async fn update_plugin( pub async fn check_plugin_updates( app_handle: AppHandle, plugins: Vec, - repositories: Vec + repositories: Vec, + server_path: String ) -> Result, String> { // Convert repository strings to RepositorySource let repos: Vec = repositories.into_iter() @@ -143,7 +144,26 @@ pub async fn check_plugin_updates( }) .collect(); - crate::services::update_manager::check_for_plugin_updates(app_handle, plugins, repos).await + // Get server info from the path for compatibility checking + let scan_result = match crate::services::plugin_scanner::perform_scan(&app_handle, &server_path).await { + Ok(result) => result, + Err(e) => { + println!("Warning: Could not get server info for compatibility check: {}", e); + // Create a minimal result with default server info + let server_info = crate::models::server::ServerInfo { + server_type: crate::models::server::ServerType::Unknown, + minecraft_version: None, + plugins_directory: format!("{}/plugins", server_path), + plugins_count: 0 + }; + crate::models::server::ScanResult { + server_info, + plugins: Vec::new() + } + } + }; + + crate::services::update_manager::check_for_plugin_updates(app_handle, plugins, repos, &scan_result.server_info).await } /// Check for updates for a single plugin @@ -151,7 +171,8 @@ pub async fn check_plugin_updates( pub async fn check_single_plugin_update_command( app_handle: AppHandle, plugin: Plugin, - repositories: Vec + repositories: Vec, + server_path: Option ) -> Result<(), String> { // Convert repository strings to RepositorySource let repos: Vec = repositories.into_iter() @@ -165,7 +186,21 @@ pub async fn check_single_plugin_update_command( }) .collect(); - crate::services::update_manager::check_single_plugin_update(app_handle, plugin, repos).await + // Get server info if a path was provided + let server_info = if let Some(path) = server_path { + match crate::services::plugin_scanner::perform_scan(&app_handle, &path).await { + Ok(result) => Some(result.server_info), + Err(e) => { + println!("Warning: Could not get server info for compatibility check: {}", e); + None + } + } + } else { + None + }; + + // Pass the optional server info to the update function + crate::services::update_manager::check_single_plugin_update(app_handle, plugin, repos, server_info.as_ref()).await } /// Create a backup of a plugin file diff --git a/src-tauri/src/commands/util_commands.rs b/src-tauri/src/commands/util_commands.rs new file mode 100644 index 0000000..5bbd3b1 --- /dev/null +++ b/src-tauri/src/commands/util_commands.rs @@ -0,0 +1,17 @@ +use serde::Serialize; +use tauri::command; +use crate::*; + +// We will use this to return the app version from Cargo.toml +#[derive(Debug, Serialize)] +pub struct AppInfo { + pub version: String, +} + +#[command] +pub fn get_app_version() -> AppInfo { + // Return the crate version from Cargo.toml + AppInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + } +} \ No newline at end of file diff --git a/src-tauri/src/crawlers/github.rs b/src-tauri/src/crawlers/github.rs index 316226b..b528ca4 100644 --- a/src-tauri/src/crawlers/github.rs +++ b/src-tauri/src/crawlers/github.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; use std::sync::Arc; use crate::models::repository::RepositoryPlugin; use crate::crawlers::Repository; +use regex; // GitHub API response structures (Based on https://docs.github.com/en/rest/releases/releases) @@ -112,6 +113,82 @@ impl GitHubCrawler { } } +// Helper function to extract Minecraft versions from text +fn extract_minecraft_versions(body: Option<&str>, description: &str) -> Vec { + let mut versions = Vec::new(); + + // Common version patterns + let version_pattern = regex::Regex::new(r"(?i)(1\.\d{1,2}(?:\.\d{1,2})?)").unwrap(); + + // Check release body + if let Some(text) = body { + for cap in version_pattern.captures_iter(text) { + if let Some(version) = cap.get(1) { + versions.push(version.as_str().to_string()); + } + } + } + + // Check description if we didn't find any versions + if versions.is_empty() { + for cap in version_pattern.captures_iter(description) { + if let Some(version) = cap.get(1) { + versions.push(version.as_str().to_string()); + } + } + } + + // If still empty, add a default + if versions.is_empty() { + versions.push("Unknown".to_string()); + } + + versions +} + +// Helper function to extract supported loaders from text +fn extract_loaders(body: Option<&str>, description: &str) -> Vec { + let mut loaders = Vec::new(); + + // Check for common loader keywords + let mut check_for_loader = |text: &str, loader_name: &str| { + if text.to_lowercase().contains(&loader_name.to_lowercase()) { + loaders.push(loader_name.to_string()); + } + }; + + // Process both body and description + let empty_string = String::new(); + let body_str = body.unwrap_or(""); + + check_for_loader(body_str, "Paper"); + check_for_loader(body_str, "Spigot"); + check_for_loader(body_str, "Bukkit"); + check_for_loader(body_str, "Forge"); + check_for_loader(body_str, "Fabric"); + check_for_loader(body_str, "Velocity"); + check_for_loader(body_str, "BungeeCord"); + check_for_loader(body_str, "Waterfall"); + + check_for_loader(description, "Paper"); + check_for_loader(description, "Spigot"); + check_for_loader(description, "Bukkit"); + check_for_loader(description, "Forge"); + check_for_loader(description, "Fabric"); + check_for_loader(description, "Velocity"); + check_for_loader(description, "BungeeCord"); + check_for_loader(description, "Waterfall"); + + // If no loaders detected, assume Bukkit/Spigot/Paper as most common + if loaders.is_empty() { + loaders.push("Bukkit".to_string()); + loaders.push("Spigot".to_string()); + loaders.push("Paper".to_string()); + } + + loaders +} + #[async_trait] impl Repository for GitHubCrawler { fn get_repository_name(&self) -> String { @@ -180,7 +257,7 @@ impl Repository for GitHubCrawler { id: repo_full_name.to_string(), name: repo.name, version: release.tag_name, - description: repo.description, + description: repo.description.clone(), authors: vec![repo.owner.login], download_url: asset.browser_download_url.clone(), repository: RepositorySource::GitHub, @@ -188,12 +265,23 @@ impl Repository for GitHubCrawler { download_count: Some(asset.download_count), last_updated: Some(release.published_at), icon_url: repo.owner.avatar_url, - minecraft_versions: Vec::new(), + minecraft_versions: extract_minecraft_versions( + release.body.as_deref(), + &repo.description.clone().unwrap_or_default() + ), categories: Vec::new(), rating: Some(repo.stargazers_count as f32), file_size: Some(asset.size), file_hash: None, - changelog: release.body, + changelog: release.body.clone(), + loaders: extract_loaders( + release.body.as_deref(), + &repo.description.clone().unwrap_or_default() + ), + supported_versions: extract_minecraft_versions( + release.body.as_deref(), + &repo.description.clone().unwrap_or_default() + ), }) } else { Err(format!("No suitable JAR asset found in the latest valid release for {}", repo_full_name)) diff --git a/src-tauri/src/crawlers/hangar.rs b/src-tauri/src/crawlers/hangar.rs index e04534d..0c4f3e5 100644 --- a/src-tauri/src/crawlers/hangar.rs +++ b/src-tauri/src/crawlers/hangar.rs @@ -34,6 +34,9 @@ struct HangarProject { icon_url: Option, created_at: String, visibility: String, + game_versions: Vec, + platform: String, + categories: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -138,12 +141,14 @@ impl Repository for HangarCrawler { download_count: Some(proj.stats.downloads), last_updated: Some(proj.last_updated), icon_url: proj.icon_url.clone(), - minecraft_versions: Vec::new(), - categories: vec![proj.category.to_string()], + minecraft_versions: proj.game_versions.clone(), + categories: proj.categories.clone(), rating: Some(proj.stats.stars as f32), file_size: None, file_hash: None, changelog: None, + loaders: vec![proj.platform.clone()], + supported_versions: proj.game_versions.clone(), } }).collect(); @@ -184,12 +189,14 @@ impl Repository for HangarCrawler { download_count: Some(project.stats.downloads), last_updated: Some(project.last_updated), icon_url: project.icon_url.clone(), - minecraft_versions: versions.first().map_or(Vec::new(), |v| v.platform_versions.clone()), - categories: vec![project.category.to_string()], + minecraft_versions: project.game_versions.clone(), + categories: project.categories.clone(), rating: Some(project.stats.stars as f32), file_size: versions.first().map(|v| v.file_size), file_hash: None, changelog: None, + loaders: vec![project.platform.clone()], + supported_versions: project.game_versions.clone(), }) } diff --git a/src-tauri/src/crawlers/modrinth.rs b/src-tauri/src/crawlers/modrinth.rs index cdb98c4..4943915 100644 --- a/src-tauri/src/crawlers/modrinth.rs +++ b/src-tauri/src/crawlers/modrinth.rs @@ -217,6 +217,8 @@ impl ModrinthCrawler { file_size: primary_file.map(|f| f.size), file_hash: primary_file.map(|f| f.hashes.sha512.clone()), // Use SHA512 changelog, + loaders: latest_version_opt.map_or(Vec::new(), |v| v.loaders.clone()), + supported_versions: latest_version_opt.map_or(project.game_versions.clone(), |v| v.game_versions.clone()), }) } @@ -334,6 +336,8 @@ impl Repository for ModrinthCrawler { file_size: None, file_hash: None, changelog: None, + loaders: Vec::new(), + supported_versions: Vec::new(), }; results.push(repo_plugin); } diff --git a/src-tauri/src/crawlers/spigotmc.rs b/src-tauri/src/crawlers/spigotmc.rs index 708a6e3..89e8376 100644 --- a/src-tauri/src/crawlers/spigotmc.rs +++ b/src-tauri/src/crawlers/spigotmc.rs @@ -172,6 +172,8 @@ impl SpigotMCCrawler { file_size: file_size_bytes, file_hash: None, // SpiGet does not provide hashes changelog: None, // Needs separate call to /updates/latest + loaders: vec!["Spigot".to_string(), "Paper".to_string()], // SpigotMC resources typically work on Spigot and Paper + supported_versions: resource.tested_versions.clone(), // Same as minecraft_versions } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c61e9e2..7e36d8a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ pub use services::update_manager::{check_for_plugin_updates, check_single_plugin // Import our commands pub use commands::plugin_commands::*; pub use commands::scan_commands::*; +pub use commands::util_commands::*; // Import our crawlers pub use crawlers::HangarCrawler; @@ -142,7 +143,7 @@ pub async fn lib_search_plugins_in_repositories( Ok(plugins) } -/// Generate search variations for a plugin name +/// Generate search variations for a plugin name using advanced techniques fn generate_search_variations(plugin_name: &str) -> Vec { let mut variations = Vec::new(); @@ -155,31 +156,272 @@ fn generate_search_variations(plugin_name: &str) -> Vec { variations.push(name_lower.clone()); } + // Normalize whitespace and separators + let normalized = normalize_separators(&name_lower); + if normalized != name_lower { + variations.push(normalized.clone()); + } + + // Extract core name by removing common prefixes/suffixes + let core_name = extract_core_name(&normalized); + if !core_name.is_empty() && core_name != normalized { + variations.push(core_name.clone()); + } + + // Handle common abbreviations and full forms + variations.extend(expand_abbreviations(&core_name)); + + // Handle word re-ordering for multi-word plugin names + if core_name.contains(' ') { + let words: Vec<&str> = core_name.split_whitespace().collect(); + if words.len() > 1 && words.len() <= 4 { // Only try reordering for 2-4 word names + // Add key words without modifiers + let significant_words: Vec<&str> = words.iter() + .filter(|w| w.len() > 3 && !is_common_modifier(w)) + .copied() + .collect(); + + if significant_words.len() > 0 && significant_words.len() < words.len() { + variations.push(significant_words.join(" ")); + } + } + } + + // Special handling for plugins that often include terms like "API", "Core", etc. + if core_name.contains("api") { + variations.push(core_name.replace("api", "").trim().to_string()); + } + + if core_name.contains("core") { + variations.push(core_name.replace("core", "").trim().to_string()); + } + + // Handle plugins that might be searched by acronym + // (e.g., "WorldEditCUI" -> "WECUI") + if let Some(acronym) = generate_acronym(&core_name) { + if acronym.len() >= 2 { + variations.push(acronym); + } + } + // Add variations with common prefixes/suffixes removed - let prefixes = ["plugin", "mc", "minecraft"]; - let suffixes = ["plugin", "spigot", "bukkit", "paper", "mc"]; + // This is more comprehensive than the original + let prefixes = ["plugin", "mc", "minecraft", "bukkit", "spigot", "paper", "forge", "fabric"]; + let suffixes = ["plugin", "spigot", "bukkit", "paper", "mc", "forge", "fabric", "addon", "plus", "+", "mod"]; for prefix in prefixes.iter() { - let prefix_str = format!("{} ", prefix); - if name_lower.starts_with(&prefix_str) { - variations.push(name_lower[prefix_str.len()..].to_string()); + // Check for space or separator after prefix + let prefix_patterns = [ + format!("{} ", prefix), // "prefix " + format!("{}-", prefix), // "prefix-" + format!("{}:", prefix), // "prefix:" + format!("{}core", prefix), // "prefixcore" + ]; + + for pattern in prefix_patterns { + if name_lower.starts_with(&pattern) { + variations.push(name_lower[pattern.len()..].trim().to_string()); + } } } for suffix in suffixes.iter() { - let suffix_str = format!(" {}", suffix); - if name_lower.ends_with(&suffix_str) { - variations.push(name_lower[0..name_lower.len() - suffix_str.len()].to_string()); + // Check for space or separator before suffix + let suffix_patterns = [ + format!(" {}", suffix), // " suffix" + format!("-{}", suffix), // "-suffix" + format!(":{}", suffix), // ":suffix" + format!("for{}", suffix), // "forsuffix" + ]; + + for pattern in suffix_patterns { + if name_lower.ends_with(&pattern) { + variations.push(name_lower[0..name_lower.len() - pattern.len()].trim().to_string()); + } } } - // Remove duplicates + // Remove duplicates and empty strings + variations.retain(|s| !s.is_empty()); variations.sort(); variations.dedup(); + // Debug: print variations + println!("Generated {} search variations for '{}':", variations.len(), plugin_name); + for (i, v) in variations.iter().enumerate() { + println!(" {}: '{}'", i+1, v); + } + variations } +/// Normalize separators in a plugin name +fn normalize_separators(name: &str) -> String { + let mut result = name.to_lowercase(); + + // Replace various separators with spaces + result = result.replace('-', " "); + result = result.replace('_', " "); + result = result.replace('.', " "); + + // Normalize spaces (multiple spaces to single space) + while result.contains(" ") { + result = result.replace(" ", " "); + } + + result.trim().to_string() +} + +/// Extract the core name by removing common prefixes/suffixes +fn extract_core_name(name: &str) -> String { + let mut result = name.to_lowercase(); + + // Common prefixes to remove + let prefixes = [ + "the ", "a ", "an ", "official ", "premium ", "free ", + "minecraft ", "mc ", "bukkit ", "spigot ", "paper ", + ]; + + // Common suffixes to remove + let suffixes = [ + " plugin", " mod", " addon", " plus", " ++", " +", " pro", + " free", " premium", " lite", " light", " full", " version", + ]; + + // Remove prefixes + for prefix in &prefixes { + if result.starts_with(prefix) { + result = result[prefix.len()..].to_string(); + } + } + + // Remove suffixes + for suffix in &suffixes { + if result.ends_with(suffix) { + result = result[..result.len() - suffix.len()].to_string(); + } + } + + // Remove version numbers at the end + // Examples: "Plugin 1.0", "Plugin v2", "Plugin 2.0.1" + if let Some(index) = result.rfind(|c: char| c == ' ' || c == 'v') { + let potential_version = &result[index+1..]; + if potential_version.chars().all(|c| c.is_digit(10) || c == '.') { + result = result[..index].to_string(); + } + } + + result.trim().to_string() +} + +/// Generate possible expansions of abbreviations +fn expand_abbreviations(name: &str) -> Vec { + let mut expansions = Vec::new(); + + // Common abbreviations in plugin names + let abbreviations = [ + ("gui", vec!["graphical user interface"]), + ("ui", vec!["user interface"]), + ("api", vec!["application programming interface", "interface"]), + ("cmds", vec!["commands"]), + ("cmd", vec!["command"]), + ("mgr", vec!["manager"]), + ("mgmt", vec!["management"]), + ("auth", vec!["authentication", "authenticator"]), + ("eco", vec!["economy"]), + ("tp", vec!["teleport", "teleporter"]), + ("inv", vec!["inventory"]), + ("prot", vec!["protection", "protector"]), + ("chat", vec!["chatter"]), + ("admin", vec!["administrator", "administration"]), + ("perms", vec!["permissions"]), + ("essentials", vec!["ess"]), + ("warp", vec!["warps"]), + ("spawn", vec!["spawner", "spawns"]), + ("we", vec!["worldedit", "world edit"]), + ("wb", vec!["worldborder", "world border"]), + ("wg", vec!["worldguard", "world guard"]), + ("lb", vec!["logblock", "log block"]), + ("gui", vec!["menu", "interface"]), + ]; + + let words: Vec<&str> = name.split_whitespace().collect(); + + // Try replacing abbreviations in each word + for (abbr, expansions_list) in &abbreviations { + for (i, word) in words.iter().enumerate() { + if word == abbr { + for expansion in expansions_list { + // Replace this word with the expansion + let mut new_words = words.clone(); + new_words[i] = expansion; + expansions.push(new_words.join(" ")); + } + } + } + } + + // Also try abbreviating common terms + for (abbr, expansions_list) in &abbreviations { + for expansion in expansions_list { + if name.contains(expansion) { + expansions.push(name.replace(expansion, abbr)); + } + } + } + + expansions +} + +/// Generate an acronym from a plugin name (e.g., "WorldEditCUI" -> "WECUI") +fn generate_acronym(name: &str) -> Option { + // Method 1: CamelCase splitting + let mut acronym = String::new(); + let chars: Vec = name.chars().collect(); + + // Special handling for CamelCase + for i in 0..chars.len() { + if i == 0 { + // Always include first character + acronym.push(chars[i].to_ascii_uppercase()); + } else if chars[i].is_uppercase() && (i == 0 || !chars[i-1].is_uppercase()) { + // Include uppercase letters that start a new word + acronym.push(chars[i]); + } + } + + // Method 2: Word splitting + if acronym.len() <= 1 { + acronym.clear(); + let words: Vec<&str> = name.split(|c: char| c == ' ' || c == '-' || c == '_').collect(); + + // Take first letter of each word + for word in words { + if !word.is_empty() { + if let Some(c) = word.chars().next() { + acronym.push(c.to_ascii_uppercase()); + } + } + } + } + + if acronym.len() >= 2 { + Some(acronym) + } else { + None + } +} + +/// Check if a word is a common modifier that doesn't add much meaning +fn is_common_modifier(word: &str) -> bool { + let modifiers = [ + "the", "a", "an", "of", "for", "with", "and", "or", "in", "on", "by", + "to", "from", "lite", "pro", "premium", "free", "plus" + ]; + + modifiers.contains(&word.to_lowercase().as_str()) +} + /// Search for plugin variations pub async fn search_with_variations(plugin_name: &str, repositories: &[RepositorySource]) -> Result, String> { let variations = generate_search_variations(plugin_name); @@ -332,6 +574,7 @@ pub fn run() { get_potential_plugin_matches, compare_versions, is_plugin_compatible, + get_app_version, greet ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/models/plugin.rs b/src-tauri/src/models/plugin.rs index b798b0f..11d2781 100644 --- a/src-tauri/src/models/plugin.rs +++ b/src-tauri/src/models/plugin.rs @@ -2,6 +2,15 @@ use serde::{Serialize, Deserialize}; use super::repository::RepositorySource; +/// Enum representing plugin compatibility status with a server +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum PluginCompatibilityStatus { + Compatible, + IncompatibleLoader, + IncompatibleMinecraftVersion, + Unknown +} + /// Represents a Minecraft plugin with detailed information #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Plugin { @@ -22,6 +31,9 @@ pub struct Plugin { pub file_path: String, pub file_hash: String, pub changelog: Option, // Changelog for the latest version + // Compatibility fields + pub compatibility_status: Option, + pub platform_compatibility: Option>, // List of compatible platforms/loaders // Fields for persistence pub repository_source: Option, pub repository_id: Option, diff --git a/src-tauri/src/models/repository.rs b/src-tauri/src/models/repository.rs index 04c6169..eab1973 100644 --- a/src-tauri/src/models/repository.rs +++ b/src-tauri/src/models/repository.rs @@ -34,6 +34,8 @@ pub struct RepositoryPlugin { pub file_size: Option, pub file_hash: Option, pub changelog: Option, // Changelog information for latest version + pub loaders: Vec, // Platforms/loaders this plugin supports (Paper, Spigot, etc.) + pub supported_versions: Vec, // Minecraft versions this plugin supports } /// Trait for crawler implementors with object safety diff --git a/src-tauri/src/services/plugin_scanner/scanner.rs b/src-tauri/src/services/plugin_scanner/scanner.rs index c2390e0..cb22640 100644 --- a/src-tauri/src/services/plugin_scanner/scanner.rs +++ b/src-tauri/src/services/plugin_scanner/scanner.rs @@ -153,6 +153,8 @@ pub async fn perform_scan(app_handle: &AppHandle, path: &str) -> Result, - repositories_to_check: Vec + repositories_to_check: Vec, + server_info: &ServerInfo, ) -> Result, String> { if installed_plugins.is_empty() { return Ok(Vec::new()); @@ -37,6 +39,7 @@ pub async fn check_for_plugin_updates( let app_handle_clone = app_handle.clone(); let repos_clone = repositories_to_check.clone(); let plugin_clone = plugin.clone(); + let server_info_clone = server_info.clone(); // Report progress let progress = BulkUpdateProgress { @@ -53,7 +56,7 @@ pub async fn check_for_plugin_updates( batch_futures.push(async move { // Process plugin println!("Processing update for plugin {}/{}: {}", index + 1, total, plugin_clone.name); - let result = process_plugin_update(plugin_clone.clone(), &repos_clone).await; + let result = process_plugin_update(plugin_clone.clone(), &repos_clone, &server_info_clone).await; (index, result) }); } @@ -97,6 +100,7 @@ pub async fn check_single_plugin_update( app_handle: AppHandle, plugin_to_check: Plugin, repositories_to_check: Vec, + server_info: Option<&ServerInfo>, ) -> Result<(), String> { // Returns Result<(), String> because result is sent via event // Begin check if let Err(e) = app_handle.emit("single_update_check_started", plugin_to_check.name.clone()) { @@ -104,7 +108,19 @@ pub async fn check_single_plugin_update( } // Process update - let updated_plugin = process_plugin_update(plugin_to_check.clone(), &repositories_to_check).await; + let updated_plugin = match server_info { + Some(info) => process_plugin_update(plugin_to_check.clone(), &repositories_to_check, info).await, + None => { + // Create a minimal server info if none provided + let default_info = ServerInfo { + server_type: ServerType::Unknown, + minecraft_version: None, + plugins_directory: String::new(), + plugins_count: 0 + }; + process_plugin_update(plugin_to_check.clone(), &repositories_to_check, &default_info).await + } + }; // Create result let result = SingleUpdateResult { @@ -122,26 +138,57 @@ pub async fn check_single_plugin_update( } /// Process update check for a single plugin -async fn process_plugin_update(plugin: Plugin, repositories: &[RepositorySource]) -> Plugin { +async fn process_plugin_update(plugin: Plugin, repositories: &[RepositorySource], server_info: &ServerInfo) -> Plugin { let mut updated_plugin = plugin.clone(); println!("Checking for updates for plugin: {}", plugin.name); // If plugin already has repository info, check that specific repo if let (Some(repo_source), Some(repo_id)) = (&plugin.repository_source, &plugin.repository_id) { println!("Plugin has existing repository info: {:?}, ID: {}", repo_source, repo_id); - match crate::lib_get_plugin_details_from_repository(repo_id, repo_source.clone(), None).await { + let result = crate::lib_get_plugin_details_from_repository(repo_id, repo_source.clone(), Some(&server_info.server_type)).await; + + match result { Ok(repo_plugin) => { println!("Successfully got details from repository for {}", plugin.name); - update_plugin_from_repo(&mut updated_plugin, &repo_plugin); + // Verify that the retrieved plugin still matches + if is_likely_match(&plugin, &repo_plugin) { + update_plugin_from_repo(&mut updated_plugin, &repo_plugin, server_info); + } else { + println!("Warning: Retrieved plugin no longer matches! Clearing repository info and trying again"); + // Clear repository info and try smart matching + updated_plugin.repository_source = None; + updated_plugin.repository_id = None; + updated_plugin = retry_with_smart_matching(updated_plugin, repositories, server_info).await; + } }, Err(e) => { println!("Failed to get update details for {}: {}", plugin.name, e); + // If we failed with the saved repository, try smart matching instead + updated_plugin = retry_with_smart_matching(updated_plugin, repositories, server_info).await; } } + + // If we have no latest_version set but we have a version, set the latest_version + // to the current version to show it's up-to-date + if updated_plugin.latest_version.is_none() { + updated_plugin.latest_version = Some(updated_plugin.version.clone()); + updated_plugin.has_update = false; + } + return updated_plugin; } // Otherwise, try smart matching with repositories + updated_plugin = retry_with_smart_matching(updated_plugin, repositories, server_info).await; + + println!("Update check complete for plugin: {}", plugin.name); + updated_plugin +} + +/// Helper function to perform smart matching when no repository info exists or previous check failed +async fn retry_with_smart_matching(plugin: Plugin, repositories: &[RepositorySource], server_info: &ServerInfo) -> Plugin { + let mut updated_plugin = plugin.clone(); + println!("No repository info for {}. Trying smart matching...", plugin.name); let matches = match crate::search_with_variations(&plugin.name, repositories).await { Ok(matches) => { @@ -150,94 +197,636 @@ async fn process_plugin_update(plugin: Plugin, repositories: &[RepositorySource] }, Err(e) => { println!("Error searching for plugin {}: {}", plugin.name, e); + // If we failed to find matches, set latest_version to current version + // to indicate it's up-to-date as far as we know + updated_plugin.latest_version = Some(updated_plugin.version.clone()); + updated_plugin.has_update = false; return updated_plugin; } }; // Find best match - for repo_plugin in matches { - if is_likely_match(&plugin, &repo_plugin) { - println!("Found likely match for {} in {:?}: {} ({})", - plugin.name, repo_plugin.repository, repo_plugin.name, repo_plugin.version); - update_plugin_from_repo(&mut updated_plugin, &repo_plugin); - break; + let mut found_match = false; + let mut best_match_score = 0.0f32; + let mut best_match_index = 0; + + // First pass: evaluate all matches to find the best one + for (i, repo_plugin) in matches.iter().enumerate() { + let match_score = calculate_match_score(&plugin, repo_plugin); + if match_score > best_match_score { + best_match_score = match_score; + best_match_index = i; } } - println!("Update check complete for plugin: {}", plugin.name); + // Second pass: if we found a likely match, use it + if best_match_score >= 3.0f32 && best_match_index < matches.len() { + let repo_plugin = &matches[best_match_index]; + + println!("Found likely match for {} in {:?}: {} ({})", + plugin.name, repo_plugin.repository, repo_plugin.name, repo_plugin.version); + + update_plugin_from_repo(&mut updated_plugin, repo_plugin, server_info); + found_match = true; + } + + // If no match was found, set latest_version to current version + if !found_match { + updated_plugin.latest_version = Some(updated_plugin.version.clone()); + updated_plugin.has_update = false; + } + updated_plugin } +/// Calculate a match score between an installed plugin and a repository plugin +fn calculate_match_score(installed: &Plugin, repo: &RepositoryPlugin) -> f32 { + if is_likely_match(installed, repo) { + // Get detailed score for debugging and sorting purposes + let mut score = 0.0f32; + + // Name similarity + let similarity = calculate_name_similarity(&installed.name, &repo.name); + if similarity == 1.0f32 { + score += 5.0f32; // Exact name match + } else if similarity > 0.9f32 { + score += 3.0f32; // Strong name match + } else if similarity > 0.7f32 { + score += 2.0f32; // Moderate name match + } else if similarity > 0.5f32 { + score += 1.0f32; // Weak name match + } + + // Main class match + if let Some(main_class) = &installed.main_class { + if main_class.to_lowercase().contains(&repo.name.to_lowercase()) { + score += 3.0f32; + } + } + + // Author match + if !installed.authors.is_empty() && !repo.authors.is_empty() { + for installed_author in &installed.authors { + for repo_author in &repo.authors { + if installed_author.to_lowercase() == repo_author.to_lowercase() { + score += 2.0f32; + break; + } + } + } + } + + // Website match + if let Some(website) = &installed.website { + if website.contains(&repo.page_url) || repo.page_url.contains(website) { + score += 2.0f32; + } + } + + // Check for matching plugin name and author + let repo_name_lower = repo.name.to_lowercase(); + + // Handle the case where installed.depend is an Option> + if let Some(depend) = &installed.depend { + if !depend.is_empty() && depend.len() > 1 { + // Score bump if repo_name appears in dependencies + let depend_str = depend.join(",").to_lowercase(); + if depend_str.contains(&repo_name_lower) { + score += 3.0f32; + } + } + + // Score bump if the plugin name matches or is contained in any dependency + if depend.iter().any(|dep| repo_name_lower.contains(&dep.to_lowercase())) { + score += 5.0f32; + } + } + + // More detailed scoring can be added here + + return score; + } + + 0.0f32 // Not a match +} + /// Update plugin with repository information -fn update_plugin_from_repo(plugin: &mut Plugin, repo_plugin: &RepositoryPlugin) { +fn update_plugin_from_repo(plugin: &mut Plugin, repo_plugin: &RepositoryPlugin, server_info: &ServerInfo) { + // Get server platform information + let server_loader = server_type_to_loader_name(&server_info.server_type); + + // Extract platform suffixes from repo version + let repo_platform = crate::services::update_manager::version_utils::extract_platform_suffix(&repo_plugin.version); + // Set repository information plugin.repository_source = Some(repo_plugin.repository.clone()); plugin.repository_id = Some(repo_plugin.id.clone()); plugin.repository_url = Some(repo_plugin.page_url.clone()); + // Set platform compatibility information + if !repo_plugin.loaders.is_empty() { + // Clean and format the loader names for display + let formatted_loaders: Vec = repo_plugin.loaders.iter() + .map(|loader| { + let loader_lower = loader.to_lowercase(); + // Standard naming conversion + if loader_lower == "paper" { "Paper".to_string() } + else if loader_lower == "spigot" { "Spigot".to_string() } + else if loader_lower == "bukkit" { "Bukkit".to_string() } + else if loader_lower == "forge" { "Forge".to_string() } + else if loader_lower == "neoforge" { "NeoForge".to_string() } + else if loader_lower == "fabric" { "Fabric".to_string() } + else if loader_lower == "quilt" { "Quilt".to_string() } + else if loader_lower == "velocity" { "Velocity".to_string() } + else if loader_lower == "bungeecord" { "BungeeCord".to_string() } + else if loader_lower == "waterfall" { "Waterfall".to_string() } + else if loader_lower == "sponge" { "Sponge".to_string() } + else { loader.clone() } // Just use original if not recognized + }) + .collect(); + + plugin.platform_compatibility = Some(formatted_loaders); + println!(" Setting platform compatibility for {}: {:?}", plugin.name, plugin.platform_compatibility); + } + + // If repo version has a specific platform suffix, check against server type + let is_platform_specific = if let Some(platform) = repo_platform { + // Check if platform suffix is compatible with server type + let platform_compatible = + if let Some(server_platform) = server_loader { + // Both have specified platforms, check if compatible + is_platform_compatible(&platform, server_platform) + } else { + // Server platform unknown, be conservative + false + }; + + if !platform_compatible { + println!(" Repo version {} has incompatible platform suffix: {}", + repo_plugin.version, platform); + // Mark as incompatible but still track the version + plugin.compatibility_status = Some(PluginCompatibilityStatus::IncompatibleLoader); + } + + // Return whether this version is platform-specific + true + } else { + false + }; + // For Modrinth, check if the version looks like an ID instead of a version number let latest_version = if repo_plugin.repository == RepositorySource::Modrinth && repo_plugin.version.len() >= 8 && !repo_plugin.version.contains('.') { // This looks like an ID rather than a version, use "Latest" as a fallback + // TODO: Potentially fetch actual version number if needed later "Latest".to_string() } else { repo_plugin.version.clone() }; - // Set latest version if newer - if latest_version != plugin.version { - let has_update = compare_plugin_versions(&plugin.version, &latest_version); + // Set latest version and check compatibility + let has_newer_version = crate::services::update_manager::version_utils::compare_plugin_versions( + &plugin.version, &latest_version); + + if has_newer_version { + println!(" Detected newer version for {}: {} -> {}", plugin.name, plugin.version, latest_version); + + // Check loader compatibility using the platform matcher + let is_loader_compatible = if is_platform_specific { + // We already checked platform compatibility above + plugin.compatibility_status != Some(PluginCompatibilityStatus::IncompatibleLoader) + } else { + // No platform suffix in version, check loaders list + is_version_compatible_with_server( + &repo_plugin.loaders, + &server_info.server_type + ) + }; + + // Check Minecraft version compatibility + let is_mc_version_compatible = if let Some(mc_version) = &server_info.minecraft_version { + // Only check if we have a server Minecraft version + is_version_compatible(mc_version, &repo_plugin.supported_versions) + } else { + // If we don't know the server version, assume it's compatible + true + }; + plugin.latest_version = Some(latest_version); - plugin.has_update = has_update; + // Only set has_update to true if all compatibility checks pass + plugin.has_update = is_loader_compatible && is_mc_version_compatible; plugin.changelog = repo_plugin.changelog.clone(); + + // Set compatibility status based on checks + if !is_loader_compatible { + plugin.compatibility_status = Some(PluginCompatibilityStatus::IncompatibleLoader); + println!(" Update found ({}) for {}, but loader is incompatible with server type {:?}", + plugin.latest_version.as_deref().unwrap_or("?"), + plugin.name, + server_info.server_type + ); + } else if !is_mc_version_compatible { + plugin.compatibility_status = Some(PluginCompatibilityStatus::IncompatibleMinecraftVersion); + println!(" Update found ({}) for {}, but it's incompatible with MC version {:?}", + plugin.latest_version.as_deref().unwrap_or("?"), + plugin.name, + server_info.minecraft_version.as_deref().unwrap_or("?") + ); + } else { + plugin.compatibility_status = Some(PluginCompatibilityStatus::Compatible); + println!(" Compatible update found ({}) for {}", + plugin.latest_version.as_deref().unwrap_or("?"), + plugin.name + ); + } + } else { + // Plugin version matches or is newer than repo - treat as up-to-date + plugin.latest_version = Some(plugin.version.clone()); plugin.has_update = false; + plugin.compatibility_status = Some(PluginCompatibilityStatus::Compatible); // Assume compatible if up-to-date + } + + // If status wasn't set (e.g., error during check before this point), mark as Unknown + if plugin.compatibility_status.is_none() { + plugin.compatibility_status = Some(PluginCompatibilityStatus::Unknown); } } -/// Determine if a repository plugin is likely a match for the installed plugin +/// Convert server type to loader name +fn server_type_to_loader_name(server_type: &ServerType) -> Option<&'static str> { + match server_type { + ServerType::Paper => Some("paper"), + ServerType::Spigot => Some("spigot"), + ServerType::Bukkit => Some("bukkit"), + ServerType::Forge => Some("forge"), + ServerType::Fabric => Some("fabric"), + ServerType::Velocity => Some("velocity"), + ServerType::BungeeCord => Some("bungeecord"), + ServerType::Waterfall => Some("waterfall"), + ServerType::Vanilla => None, // Vanilla doesn't support plugins + ServerType::Unknown => None, + } +} + +/// Check if version platform is compatible with server platform +fn is_platform_compatible(version_platform: &str, server_platform: &str) -> bool { + // Direct platform match + if version_platform.eq_ignore_ascii_case(server_platform) { + return true; + } + + // Platform compatibility matrix + match server_platform { + "paper" => { + // Paper is compatible with Spigot and Bukkit plugins + version_platform == "spigot" || + version_platform == "bukkit" + }, + "spigot" => { + // Spigot is compatible with Bukkit plugins + version_platform == "bukkit" + }, + "bungeecord" => { + // BungeeCord is compatible with Waterfall plugins and vice versa + version_platform == "waterfall" + }, + "waterfall" => { + version_platform == "bungeecord" + }, + _ => false // No other cross-compatibility + } +} + +/// Determine if a repository plugin is likely a match for an installed plugin +/// Uses multiple weighted factors and confidence scoring fn is_likely_match(installed: &Plugin, repo: &RepositoryPlugin) -> bool { - // Require multiple criteria to match to avoid false positives - let mut match_points = 0; + println!("Evaluating match: '{}' (installed) vs '{}' (repo)", installed.name, repo.name); - // Name similarity check + // Multi-factor weighted scoring system + let mut confidence_score = 0.0f32; + let mut factor_count = 0; + let mut applied_weights = 0.0f32; + + // Track applied factors for debugging + let mut applied_factors = Vec::new(); + + // ====== NAME SIMILARITY (HIGHEST WEIGHT) ====== let name_similarity = calculate_name_similarity(&installed.name, &repo.name); + let name_weight = 10.0f32; + applied_weights += name_weight; + factor_count += 1; + confidence_score += name_similarity * name_weight; + applied_factors.push(format!("Name similarity: {:.2} (weight: {:.1})", name_similarity, name_weight)); - // Strong name match (over 0.9 similarity) - if name_similarity > 0.9 { - match_points += 2; - } - // Moderate name match - else if name_similarity > 0.7 { - match_points += 1; + // Exact name match is very strong signal + if name_similarity > 0.95f32 { + applied_factors.push("STRONG SIGNAL: Nearly exact name match".to_string()); } - // Author check - if any author matches + // ====== DESCRIPTION SIMILARITY ====== + if let (Some(installed_desc), Some(repo_desc)) = (&installed.description, &repo.description) { + if !installed_desc.is_empty() && !repo_desc.is_empty() { + let desc_similarity = calculate_text_similarity(installed_desc, repo_desc); + let desc_weight = 3.0f32; + applied_weights += desc_weight; + factor_count += 1; + confidence_score += desc_similarity * desc_weight; + applied_factors.push(format!("Description similarity: {:.2} (weight: {:.1})", desc_similarity, desc_weight)); + } + } + + // ====== MAIN CLASS MATCH (VERY STRONG SIGNAL) ====== + if let Some(main_class) = &installed.main_class { + let main_class_lower = main_class.to_lowercase(); + let repo_name_lower = repo.name.to_lowercase(); + + // Check if repository plugin name is contained in the main class + // This is a very strong signal that they are the same plugin + let main_class_signal = if main_class_lower.contains(&repo_name_lower) { + // Direct containment is strongest + let weight = 8.0f32; + applied_weights += weight; + factor_count += 1; + confidence_score += 1.0f32 * weight; + applied_factors.push(format!("Main class contains repo name (weight: {:.1})", weight)); + true + } else if repo_name_lower.contains(&main_class_lower.split('.').last().unwrap_or("").to_lowercase()) { + // Final component of package matches + let weight = 6.0f32; + applied_weights += weight; + factor_count += 1; + confidence_score += 0.9f32 * weight; + applied_factors.push(format!("Repo name contains last part of main class (weight: {:.1})", weight)); + true + } else { + // Check for partial matching (e.g., WordlEdit matches com.sk89q.worldedit) + let main_class_parts: Vec<&str> = main_class_lower.split('.').collect(); + for part in main_class_parts { + if part.len() > 3 && repo_name_lower.contains(part) { + let weight = 5.0f32; + applied_weights += weight; + factor_count += 1; + confidence_score += 0.8f32 * weight; + applied_factors.push(format!("Main class part '{}' found in repo name (weight: {:.1})", part, weight)); + return true; + } + } + false + }; + + if main_class_signal { + applied_factors.push("STRONG SIGNAL: Main class match".to_string()); + } + } + + // ====== AUTHOR MATCH ====== if !installed.authors.is_empty() && !repo.authors.is_empty() { + let mut best_author_match = 0.0f32; + let mut matched_author = String::new(); + for installed_author in &installed.authors { + let installed_author_lower = installed_author.to_lowercase(); + for repo_author in &repo.authors { - if installed_author.to_lowercase() == repo_author.to_lowercase() { - match_points += 2; + let repo_author_lower = repo_author.to_lowercase(); + + // Try different author name formats and variations + if installed_author_lower == repo_author_lower { + best_author_match = 1.0f32; + matched_author = repo_author.clone(); break; } + + // Handle partial name matches (e.g., "JohnDoe" vs "John Doe" or "jdoe") + let similarity = calculate_name_similarity(&installed_author_lower, &repo_author_lower); + if similarity > best_author_match { + best_author_match = similarity; + matched_author = repo_author.clone(); + } + } + + if best_author_match > 0.0f32 { + break; + } + } + + if best_author_match > 0.0f32 { + let author_weight = if best_author_match >= 0.9f32 { 6.0f32 } else { 3.0f32 }; + applied_weights += author_weight; + factor_count += 1; + confidence_score += best_author_match * author_weight; + applied_factors.push(format!("Author match: {} ({:.2} similarity, weight: {:.1})", + matched_author, best_author_match, author_weight)); + + if best_author_match >= 0.9f32 { + applied_factors.push("STRONG SIGNAL: Author exact match".to_string()); + } + } + } + + // ====== WEBSITE MATCH ====== + if let Some(website) = &installed.website { + let website_lower = website.to_lowercase(); + let repo_url_lower = repo.page_url.to_lowercase(); + + if website_lower.contains(&repo_url_lower) || repo_url_lower.contains(&website_lower) { + let weight = 4.0f32; + applied_weights += weight; + factor_count += 1; + confidence_score += 1.0f32 * weight; + applied_factors.push(format!("Website match (weight: {:.1})", weight)); + applied_factors.push("STRONG SIGNAL: Website match".to_string()); + } else { + // Try to extract domains for partial matching + let website_domain = extract_domain(&website_lower); + let repo_domain = extract_domain(&repo_url_lower); + + if let (Some(w_domain), Some(r_domain)) = (website_domain, repo_domain) { + if w_domain == r_domain { + let weight = 3.0f32; + applied_weights += weight; + factor_count += 1; + confidence_score += 0.9f32 * weight; + applied_factors.push(format!("Domain match: {} (weight: {:.1})", w_domain, weight)); + } } } } - // Website match - if let Some(website) = &installed.website { - if website.contains(&repo.page_url) || repo.page_url.contains(website) { - match_points += 2; + // ====== DEPENDENCY CHECKS ====== + // Check if plugins have similar dependencies + // This helps distinguish plugins with similar names but different functionality + if let Some(depend) = &installed.depend { + if !depend.is_empty() { + // Create a string of dependencies for matching + let depend_str = depend.join(",").to_lowercase(); + let repo_name_lower = repo.name.to_lowercase(); + + // Watch for potential false matches where repo name matches a dependency + // This prevents matching a plugin to its dependency (like WorldEdit to FastAsyncWorldEdit) + let mut dependency_penalty = false; + for dep in depend { + if repo_name_lower.contains(&dep.to_lowercase()) { + dependency_penalty = true; + let weight = -5.0f32; // Strong negative signal + applied_weights += weight.abs(); + confidence_score += weight; // Negative score + applied_factors.push(format!("NEGATIVE: Repo name matches dependency: {} (weight: {:.1})", dep, weight)); + break; + } + } + + // Check if description contains dependency references + if !dependency_penalty && !repo_name_lower.contains("api") && !repo_name_lower.contains("lib") { + if let Some(desc) = &repo.description { + let desc_lower = desc.to_lowercase(); + + // Check if any dependencies are mentioned in the description + let mut found_dependencies = Vec::new(); + for dep in depend { + let dep_lower = dep.to_lowercase(); + if desc_lower.contains(&dep_lower) { + found_dependencies.push(dep.clone()); + } + } + + if !found_dependencies.is_empty() { + let weight = 3.0f32; + let match_score = (found_dependencies.len() as f32 / depend.len() as f32).min(1.0); + applied_weights += weight; + factor_count += 1; + confidence_score += match_score * weight; + applied_factors.push(format!("Dependencies in description: {} (weight: {:.1})", + found_dependencies.join(", "), weight)); + } + } + } } } - // Require at least 2 match points to consider it a likely match - // This helps avoid incorrect matches - match_points >= 2 + // ====== FINAL CONFIDENCE CALCULATION ====== + let normalized_confidence = if applied_weights > 0.0f32 { + confidence_score / applied_weights + } else { + 0.0f32 + }; + + // Calculate a weighted score that considers both total confidence and breadth of signals + let signal_breadth_factor = (factor_count as f32 / 5.0).min(1.0); // Normalize to max of 5 factors + let final_confidence = normalized_confidence * 0.7 + signal_breadth_factor * 0.3; + + // Threshold for likely match + let is_match = final_confidence >= 0.6 && factor_count >= 2; + + // Debug output + println!("Match analysis for '{}' vs '{}':", installed.name, repo.name); + for factor in &applied_factors { + println!(" {}", factor); + } + println!(" Factors: {}, Applied weights: {:.1}, Confidence score: {:.1}", + factor_count, applied_weights, confidence_score); + println!(" Normalized confidence: {:.2}, Signal breadth: {:.2}, Final confidence: {:.2}", + normalized_confidence, signal_breadth_factor, final_confidence); + println!(" MATCH RESULT: {}", if is_match { "LIKELY MATCH ✓" } else { "NOT A MATCH ✗" }); + + is_match } -/// Calculate similarity between plugin names (simple implementation) +/// Extract domain from a URL for website matching +fn extract_domain(url: &str) -> Option { + // Strip protocol + let without_protocol = if url.starts_with("http://") { + &url[7..] + } else if url.starts_with("https://") { + &url[8..] + } else { + url + }; + + // Find end of domain (first slash or end of string) + let domain_end = without_protocol.find('/').unwrap_or(without_protocol.len()); + let domain = &without_protocol[0..domain_end]; + + // Remove www. prefix if present + let domain = if domain.starts_with("www.") { + &domain[4..] + } else { + domain + }; + + if domain.is_empty() || !domain.contains('.') { + None + } else { + Some(domain.to_string()) + } +} + +/// Calculate similarity between two text fields +fn calculate_text_similarity(text1: &str, text2: &str) -> f32 { + let t1 = text1.to_lowercase(); + let t2 = text2.to_lowercase(); + + // For short texts, use character-level similarity + if t1.len() < 10 || t2.len() < 10 { + return calculate_levenshtein_similarity(&t1, &t2); + } + + // Otherwise use a bag-of-words approach + let words1: Vec<&str> = t1.split_whitespace().collect(); + let words2: Vec<&str> = t2.split_whitespace().collect(); + + if words1.is_empty() || words2.is_empty() { + return 0.0; + } + + // Count word occurrences + let mut word_counts1 = std::collections::HashMap::new(); + let mut word_counts2 = std::collections::HashMap::new(); + + for word in &words1 { + *word_counts1.entry(*word).or_insert(0) += 1; + } + + for word in &words2 { + *word_counts2.entry(*word).or_insert(0) += 1; + } + + // Calculate intersection size + let mut intersection = 0; + let mut important_word_matches = 0; + + for (word, count1) in &word_counts1 { + if word.len() <= 3 { + continue; // Skip short common words + } + + if let Some(count2) = word_counts2.get(word) { + intersection += count1.min(count2); + + // Consider longer words more important + if word.len() >= 5 { + important_word_matches += 1; + } + } + } + + // Calculate Jaccard similarity + let union = words1.len() + words2.len() - intersection; + let jaccard = if union > 0 { + intersection as f32 / union as f32 + } else { + 0.0 + }; + + // Boost score if important words match + let importance_bonus = (important_word_matches as f32 * 0.1).min(0.3); + + (jaccard + importance_bonus).min(1.0) +} + +/// Calculate similarity between plugin names using an improved algorithm fn calculate_name_similarity(name1: &str, name2: &str) -> f32 { let name1_lower = name1.to_lowercase(); let name2_lower = name2.to_lowercase(); @@ -247,30 +836,184 @@ fn calculate_name_similarity(name1: &str, name2: &str) -> f32 { return 1.0; } - // Contains check - if name1_lower.contains(&name2_lower) || name2_lower.contains(&name1_lower) { - return 0.9; + // First, normalize the names by removing common irrelevant words + let name1_normalized = normalize_plugin_name(&name1_lower); + let name2_normalized = normalize_plugin_name(&name2_lower); + + // Check normalized equality + if name1_normalized == name2_normalized { + return 0.95; // Almost perfect match after normalization } - // Simple word matching - let words1: Vec<&str> = name1_lower.split_whitespace().collect(); - let words2: Vec<&str> = name2_lower.split_whitespace().collect(); + // Contains check with improved weighting + if name1_normalized.contains(&name2_normalized) || name2_normalized.contains(&name1_normalized) { + // Calculate containment score based on length ratios + let length_ratio = name1_normalized.len() as f32 / name2_normalized.len().max(1) as f32; + let normalized_ratio = if length_ratio > 1.0 { 1.0 / length_ratio } else { length_ratio }; - let mut matched_words = 0; - let total_words = words1.len().max(words2.len()); + // Higher score for closer length ratios + return 0.7 + (normalized_ratio * 0.2); + } + + // Split by common separators and compute word-level similarity + let words1: Vec<&str> = name1_normalized + .split(|c| c == ' ' || c == '-' || c == '_') + .filter(|w| !w.is_empty() && w.len() > 2) + .collect(); + + let words2: Vec<&str> = name2_normalized + .split(|c| c == ' ' || c == '-' || c == '_') + .filter(|w| !w.is_empty() && w.len() > 2) + .collect(); + + if words1.is_empty() || words2.is_empty() { + // Fallback to character-level similarity for short names or single words + return calculate_levenshtein_similarity(&name1_normalized, &name2_normalized); + } + + // Word-level similarity calculation with TF-IDF style weighting + let mut similarity_score = 0.0f32; + let mut total_weight = 0.0f32; for word1 in &words1 { + // Words with higher length get more weight (similar to IDF) + let word_weight = (word1.len() as f32).min(5.0) / 5.0; + total_weight += word_weight; + + let mut best_word_match: f32 = 0.0f32; + for word2 in &words2 { - if word1 == word2 && word1.len() > 2 { // Ignore short words - matched_words += 1; - break; + // For each word, find the best matching word in the other name + let word_similarity = if word1 == word2 { + 1.0f32 // Exact word match + } else { + calculate_levenshtein_similarity(word1, word2) + }; + + best_word_match = best_word_match.max(word_similarity); + } + + similarity_score += best_word_match * word_weight; + } + + // Normalize by total weight + if total_weight > 0.0 { + similarity_score /= total_weight; + } + + // Extra boost for plugins with first word matching + // This helps for plugins like "WorldEdit" vs "FastWorldEdit" + if !words1.is_empty() && !words2.is_empty() && words1[0] == words2[0] { + similarity_score = (similarity_score + 0.2).min(0.9); + } + + similarity_score +} + +/// Normalize a plugin name by removing common prefixes, suffixes, and irrelevant words +fn normalize_plugin_name(name: &str) -> String { + let mut normalized = name.to_lowercase(); + + // Remove common version indicators + let version_patterns = [ + " v1", " v2", " v3", " v4", " v5", + " 1.0", " 2.0", " 3.0", " 4.0", " 5.0", + " (1.", " (2.", " (3.", + ]; + + for pattern in &version_patterns { + if let Some(pos) = normalized.find(pattern) { + normalized = normalized[0..pos].to_string(); + } + } + + // Remove common irrelevant words + let irrelevant_words = [ + "plugin", "mod", "minecraft", "bukkit", "spigot", "paper", "forge", "fabric", + "the", "for", "and", "lite", "full", "version", "free", "premium", "pro" + ]; + + for word in &irrelevant_words { + let pattern = format!(" {} ", word); + normalized = normalized.replace(&pattern, " "); + + // Also check at the beginning and end + let start_pattern = format!("{} ", word); + let end_pattern = format!(" {}", word); + + normalized = normalized.replace(&start_pattern, ""); + normalized = normalized.replace(&end_pattern, ""); + } + + // Trim and remove duplicate spaces + normalized = normalized.trim().to_string(); + while normalized.contains(" ") { + normalized = normalized.replace(" ", " "); + } + + normalized +} + +/// Calculate Levenshtein similarity between two strings +fn calculate_levenshtein_similarity(s1: &str, s2: &str) -> f32 { + if s1.is_empty() || s2.is_empty() { + return if s1.is_empty() && s2.is_empty() { 1.0 } else { 0.0 }; + } + + // Optimization for strings with large length difference + let len_diff = (s1.len() as isize - s2.len() as isize).abs() as usize; + if len_diff > s1.len() / 2 || len_diff > s2.len() / 2 { + return 0.2; // Low similarity for strings with very different lengths + } + + // Early exact matching check + if s1 == s2 { + return 1.0; + } + + // Convert strings to character vectors for easier access + let s1_chars: Vec = s1.chars().collect(); + let s2_chars: Vec = s2.chars().collect(); + let len1 = s1_chars.len(); + let len2 = s2_chars.len(); + + // Create matrix for dynamic programming + let mut matrix = vec![vec![0; len2 + 1]; len1 + 1]; + + // Initialize first row and column + for i in 0..=len1 { + matrix[i][0] = i; + } + for j in 0..=len2 { + matrix[0][j] = j; + } + + // Fill the matrix + for i in 1..=len1 { + for j in 1..=len2 { + let cost = if s1_chars[i-1] == s2_chars[j-1] { 0 } else { 1 }; + + matrix[i][j] = [ + matrix[i-1][j] + 1, // deletion + matrix[i][j-1] + 1, // insertion + matrix[i-1][j-1] + cost, // substitution + ].iter().min().unwrap().clone(); + + // Transposition (swap two adjacent characters) + if i > 1 && j > 1 && s1_chars[i-1] == s2_chars[j-2] && s1_chars[i-2] == s2_chars[j-1] { + matrix[i][j] = matrix[i-2][j-2] + 1; } } } - if total_words > 0 { - matched_words as f32 / total_words as f32 + // Calculate normalized similarity + let distance = matrix[len1][len2] as f32; + let max_length = len1.max(len2) as f32; + + // Normalize to a similarity score (1.0 = identical, 0.0 = completely different) + if max_length > 0.0 { + (max_length - distance) / max_length } else { - 0.0 + 1.0 } } \ No newline at end of file diff --git a/src-tauri/src/services/update_manager/version_utils.rs b/src-tauri/src/services/update_manager/version_utils.rs index 4137a34..336df67 100644 --- a/src-tauri/src/services/update_manager/version_utils.rs +++ b/src-tauri/src/services/update_manager/version_utils.rs @@ -3,36 +3,283 @@ use regex::Regex; /// Normalize a version string to be semver compatible pub fn normalize_version(version_str: &str) -> String { - // If already starts with a digit, assume semantic version format - if version_str.chars().next().map_or(false, |c| c.is_ascii_digit()) { - // Clean up any common prefixes like 'v' - let cleaned = version_str.trim_start_matches(|c| c == 'v' || c == 'V'); - return cleaned.to_string(); + // Handle empty strings or "Latest" + if version_str.is_empty() || version_str.eq_ignore_ascii_case("latest") { + return "0.0.0".to_string(); // Default version if none provided } - // Return as-is for now - version_str.to_string() + // Remove 'v' prefix if present + let cleaned = version_str.trim_start_matches(|c| c == 'v' || c == 'V'); + + // Remove double 'v' prefixes (like "vv1.0.0") + let cleaned = if cleaned.starts_with("v") || cleaned.starts_with("V") { + cleaned.trim_start_matches(|c| c == 'v' || c == 'V') + } else { + cleaned + }; + + cleaned.to_string() +} + +/// Sanitize version string for comparison by removing platform suffixes +fn sanitize_version_for_comparison(version_str: &str) -> String { + // First normalize the version + let normalized = normalize_version(version_str); + + // Remove common platform-specific suffixes + let platform_suffixes = [ + "-paper", "-spigot", "-bukkit", "-forge", "-fabric", "-neoforge", + "-sponge", "-velocity", "-waterfall", "-bungeecord", "-quilt" + ]; + + let mut result = normalized.to_string(); + + // Case-insensitive suffix removal + for suffix in platform_suffixes.iter() { + let suffix_lower = suffix.to_lowercase(); + let version_lower = result.to_lowercase(); + + if version_lower.contains(&suffix_lower) { + // Find the position of the suffix (case-insensitive) + if let Some(pos) = version_lower.find(&suffix_lower) { + // Remove the suffix (with original casing) + result = result[0..pos].to_string(); + } + } + } + + // Remove any build metadata (anything after +) + if let Some(plus_pos) = result.find('+') { + result = result[0..plus_pos].to_string(); + } + + // Handle snapshot versions with build numbers + if result.contains("-SNAPSHOT") || result.contains("-snapshot") { + // Extract just the version part before any build info + if let Some(snapshot_pos) = result.to_lowercase().find("-snapshot") { + result = result[0..snapshot_pos].to_string(); + } + } + + // Normalize dev build formats that use dash-separated numbers + let build_regex = Regex::new(r"-build\d+").unwrap(); + result = build_regex.replace_all(&result, "").to_string(); + + // Handle other common non-numeric suffixes + let common_suffixes = ["-RELEASE", "-dev", "-final", "-stable"]; + for suffix in common_suffixes.iter() { + let suffix_lower = suffix.to_lowercase(); + let version_lower = result.to_lowercase(); + + if version_lower.ends_with(&suffix_lower) { + let suffix_len = suffix_lower.len(); + result = result[0..result.len() - suffix_len].to_string(); + } + } + + // If we've removed everything, return the original normalized version + if result.is_empty() { + return normalized; + } + + // Make sure it ends with at least one digit for semver parsing + if !result.chars().last().map_or(false, |c| c.is_ascii_digit()) { + if let Some(last_digit_pos) = result.rfind(|c: char| c.is_ascii_digit()) { + result = result[0..=last_digit_pos].to_string(); + } + } + + // If we've removed version numbers entirely, return the original + if !result.chars().any(|c| c.is_ascii_digit()) { + return normalized; + } + + result +} + +/// Extracts platform suffix from a version string +pub fn extract_platform_suffix(version_str: &str) -> Option { + // Platform suffixes to check for + let platform_suffixes = [ + "-paper", "-spigot", "-bukkit", "-forge", "-fabric", "-neoforge", + "-sponge", "-velocity", "-waterfall", "-bungeecord", "-quilt" + ]; + + let version_lower = version_str.to_lowercase(); + + for suffix in platform_suffixes.iter() { + let suffix_lower = suffix.to_lowercase(); + if version_lower.contains(&suffix_lower) { + // Return the actual platform with original case + return Some(suffix_lower[1..].to_string()); // Remove the leading '-' + } + } + + None +} + +/// Determine if a version is a pre-release (snapshot, beta, etc.) +fn is_prerelease_version(version_str: &str) -> bool { + let version_lower = version_str.to_lowercase(); + + // Check for common pre-release indicators + version_lower.contains("-snapshot") || + version_lower.contains("-alpha") || + version_lower.contains("-beta") || + version_lower.contains("-pre") || + version_lower.contains("-rc") || + version_lower.contains("dev") || + version_lower.contains("test") || + version_lower.contains("nightly") } /// Compare two plugin versions to determine if an update is available +/// Returns true if repo_str represents a newer version than installed_str pub fn compare_plugin_versions(installed_str: &str, repo_str: &str) -> bool { - // Normalize version strings - let installed_version = normalize_version(installed_str); - let repo_version = normalize_version(repo_str); + // Special case: identical strings are never upgrades + if installed_str == repo_str { + return false; + } + + // Extract platform suffixes + let installed_platform = extract_platform_suffix(installed_str); + let repo_platform = extract_platform_suffix(repo_str); + + // If platforms differ and both are specified, it's not considered an upgrade + // (we don't want to suggest forge versions for paper plugins) + if let (Some(installed_p), Some(repo_p)) = (&installed_platform, &repo_platform) { + if installed_p != repo_p { + println!("Platforms differ: {} vs {}, not an upgrade", installed_p, repo_p); + return false; + } + } + + // Check for downgrades from release to prerelease + let installed_is_prerelease = is_prerelease_version(installed_str); + let repo_is_prerelease = is_prerelease_version(repo_str); + + // Don't consider a pre-release version an upgrade from a stable version + if !installed_is_prerelease && repo_is_prerelease { + println!("Not upgrading from release {} to pre-release {}", installed_str, repo_str); + return false; + } + + // Sanitize versions for comparison by removing platform-specific suffixes + let sanitized_installed = sanitize_version_for_comparison(installed_str); + let sanitized_repo = sanitize_version_for_comparison(repo_str); + + // Log for debugging + println!("Comparing versions: '{}'({}') vs '{}'('{}')", + installed_str, sanitized_installed, repo_str, sanitized_repo); // Try to parse as semver - match (Version::parse(&installed_version), Version::parse(&repo_version)) { + match (Version::parse(&sanitized_installed), Version::parse(&sanitized_repo)) { (Ok(installed), Ok(repo)) => { - // Simple semver comparison + // Properly formatted semver comparison + println!(" Using semver comparison: {} vs {}", installed, repo); repo > installed }, _ => { - // Fallback to simple string comparison for non-semver versions - repo_version != installed_version + // Fallback to more sophisticated string comparison for non-semver versions + if sanitized_installed == sanitized_repo { + // Same base version, check if repo has a higher build number or qualifier + compare_version_qualifiers(installed_str, repo_str) + } else { + // Try numeric component-by-component comparison + compare_version_components(&sanitized_installed, &sanitized_repo) + } } } } +/// Compare version qualifiers and build numbers when base versions are the same +fn compare_version_qualifiers(installed_str: &str, repo_str: &str) -> bool { + // Try to extract build numbers + let installed_build = extract_build_number(installed_str); + let repo_build = extract_build_number(repo_str); + + if let (Some(i_build), Some(r_build)) = (installed_build, repo_build) { + return r_build > i_build; + } + + // If qualifiers differ, use a rank-based system + let installed_rank = get_qualifier_rank(installed_str); + let repo_rank = get_qualifier_rank(repo_str); + + if installed_rank != repo_rank { + return repo_rank > installed_rank; + } + + // Default case - not considered an upgrade + false +} + +/// Assign rank values to different version qualifiers +fn get_qualifier_rank(version_str: &str) -> i32 { + let lower_str = version_str.to_lowercase(); + + if lower_str.contains("alpha") { return 10; } + if lower_str.contains("beta") { return 20; } + if lower_str.contains("rc") { return 30; } + if lower_str.contains("pre") { return 40; } + if lower_str.contains("snapshot") { return 50; } + if lower_str.contains("nightly") { return 60; } + if lower_str.contains("dev") { return 70; } + if lower_str.contains("release") { return 100; } + + // Default for stable releases + 90 +} + +/// Extract build number from a version string +fn extract_build_number(version_str: &str) -> Option { + // Try to find patterns like "build123" or "-b123" or ".123" + let build_patterns = [ + Regex::new(r"build(\d+)").ok()?, + Regex::new(r"-b(\d+)").ok()?, + Regex::new(r"\.(\d+)$").ok()?, + Regex::new(r"-(\d+)$").ok()?, + ]; + + for pattern in &build_patterns { + if let Some(captures) = pattern.captures(version_str) { + if let Some(build_match) = captures.get(1) { + if let Ok(build) = build_match.as_str().parse::() { + return Some(build); + } + } + } + } + + None +} + +/// Compare version strings component by component +fn compare_version_components(version1: &str, version2: &str) -> bool { + // Split versions into numeric and non-numeric parts + let v1_parts: Vec<&str> = version1.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).collect(); + let v2_parts: Vec<&str> = version2.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).collect(); + + // Compare corresponding numeric parts + let max_parts = v1_parts.len().max(v2_parts.len()); + + for i in 0..max_parts { + let n1 = v1_parts.get(i).and_then(|s| s.parse::().ok()).unwrap_or(0); + let n2 = v2_parts.get(i).and_then(|s| s.parse::().ok()).unwrap_or(0); + + if n2 > n1 { + return true; // Version 2 is higher + } + if n1 > n2 { + return false; // Version 1 is higher + } + } + + // If all numeric parts are equal, compare by string length + // (Consider more segments to be a higher version, like 1.2.3 > 1.2) + v2_parts.len() > v1_parts.len() +} + /// Extract version pattern from a string pub fn extract_version_pattern(input: &str) -> Option { // Look for version pattern like 1.19.2 in string @@ -46,15 +293,34 @@ pub fn extract_version_pattern(input: &str) -> Option { } /// Check if a plugin version is compatible with a specific Minecraft version -pub fn is_version_compatible(plugin_version: &str, minecraft_version: &str) -> bool { - // Try to parse the Minecraft version - if let Ok(mc_version) = Version::parse(minecraft_version) { - // Try to parse as a version requirement - if let Ok(req) = VersionReq::parse(plugin_version) { - return req.matches(&mc_version); +pub fn is_version_compatible(plugin_version: &str, minecraft_versions: &Vec) -> bool { + // If no versions specified, assume compatible + if minecraft_versions.is_empty() { + return true; + } + + // Check if the Minecraft version is in the list of supported versions + for supported_version in minecraft_versions { + if plugin_version == supported_version { + return true; + } + + // Try to parse the Minecraft version + if let Ok(mc_version) = semver::Version::parse(plugin_version) { + // Try to parse as a version requirement + if let Ok(req) = semver::VersionReq::parse(supported_version) { + if req.matches(&mc_version) { + return true; + } + } + } + + // If version formats are incompatible, make best guess + if plugin_version.contains(supported_version) || supported_version.contains(plugin_version) { + return true; } } - // If version formats are incompatible, make best guess - plugin_version.contains(minecraft_version) + // No compatibility match found + false } \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9dee09c..7f3620d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -40,7 +40,7 @@ }, "plugins": { "shell": { - "open": true + "open": "^((mailto:\\w+)|(tel:\\w+)|(https?://\\w+)|(file://.+)).+" }, "dialog": null, "fs": null diff --git a/src/App.tsx b/src/App.tsx index 30f7f09..832809b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import './App.css'; // Import context providers @@ -38,6 +38,15 @@ import { useServerContext } from './context/ServerContext/useServerContext'; import { usePluginContext } from './context/PluginContext/usePluginContext'; import { usePluginActions } from './hooks/usePluginActions'; +// Define interfaces for component props +interface PluginContextWrapperProps { + appVersion: string; +} + +interface AppContentProps { + appVersion: string; +} + /** * The main application component that serves as the entry point. * This component is responsible for setting up the context providers @@ -45,10 +54,29 @@ import { usePluginActions } from './hooks/usePluginActions'; */ function App() { console.log("App Component Initialized"); + const [appVersion, setAppVersion] = useState('1.0.0'); + + useEffect(() => { + // Get the app version from the backend + const getAppVersion = async () => { + try { + // Use Tauri's invoke to call our Rust command + const { invoke } = await import('@tauri-apps/api/core'); + const appInfo = await invoke('get_app_version'); + setAppVersion((appInfo as { version: string }).version); + } catch (error) { + console.error('Failed to get app version:', error); + // display unknown version + setAppVersion('unknown'); + } + }; + + getAppVersion(); + }, []); return ( - + ); } @@ -56,7 +84,7 @@ function App() { /** * Wrapper to ensure PluginProvider has access to ServerContext */ -function PluginContextWrapper() { +function PluginContextWrapper({ appVersion }: PluginContextWrapperProps) { const { serverPath, serverInfo } = useServerContext(); useEffect(() => { @@ -66,7 +94,7 @@ function PluginContextWrapper() { return ( - + ); @@ -76,7 +104,7 @@ function PluginContextWrapper() { * The main application content that uses context hooks. * This is separate from App to ensure the contexts are available. */ -function AppContent() { +function AppContent({ appVersion }: AppContentProps) { const { serverInfo, serverPath, @@ -201,7 +229,7 @@ function AppContent() { )} -