Enhance app version management and improve plugin list filtering with advanced search capabilities

This commit is contained in:
Rbanh 2025-04-01 01:37:55 -04:00
parent 61becf8d22
commit 0240ab9c50
19 changed files with 1771 additions and 176 deletions

View File

@ -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",

View File

@ -1,2 +1,3 @@
pub mod plugin_commands;
pub mod scan_commands;
pub mod scan_commands;
pub mod util_commands;

View File

@ -129,7 +129,8 @@ pub async fn update_plugin(
pub async fn check_plugin_updates(
app_handle: AppHandle,
plugins: Vec<Plugin>,
repositories: Vec<String>
repositories: Vec<String>,
server_path: String
) -> Result<Vec<Plugin>, String> {
// Convert repository strings to RepositorySource
let repos: Vec<RepositorySource> = 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<String>
repositories: Vec<String>,
server_path: Option<String>
) -> Result<(), String> {
// Convert repository strings to RepositorySource
let repos: Vec<RepositorySource> = 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

View File

@ -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(),
}
}

View File

@ -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<String> {
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<String> {
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))

View File

@ -34,6 +34,9 @@ struct HangarProject {
icon_url: Option<String>,
created_at: String,
visibility: String,
game_versions: Vec<String>,
platform: String,
categories: Vec<String>,
}
#[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(),
})
}

View File

@ -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);
}

View File

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

View File

@ -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<String> {
let mut variations = Vec::new();
@ -155,31 +156,272 @@ fn generate_search_variations(plugin_name: &str) -> Vec<String> {
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<String> {
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<String> {
// Method 1: CamelCase splitting
let mut acronym = String::new();
let chars: Vec<char> = 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<Vec<RepositoryPlugin>, 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!())

View File

@ -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<String>, // Changelog for the latest version
// Compatibility fields
pub compatibility_status: Option<PluginCompatibilityStatus>,
pub platform_compatibility: Option<Vec<String>>, // List of compatible platforms/loaders
// Fields for persistence
pub repository_source: Option<RepositorySource>,
pub repository_id: Option<String>,

View File

@ -34,6 +34,8 @@ pub struct RepositoryPlugin {
pub file_size: Option<u64>,
pub file_hash: Option<String>,
pub changelog: Option<String>, // Changelog information for latest version
pub loaders: Vec<String>, // Platforms/loaders this plugin supports (Paper, Spigot, etc.)
pub supported_versions: Vec<String>, // Minecraft versions this plugin supports
}
/// Trait for crawler implementors with object safety

View File

@ -153,6 +153,8 @@ pub async fn perform_scan(app_handle: &AppHandle, path: &str) -> Result<ScanResu
file_path: meta.file_path,
file_hash: meta.file_hash,
changelog: None, // Will be populated during update check
compatibility_status: None, // Will be populated during update check
platform_compatibility: None, // Will be populated during update check
repository_source: None, // Will be populated during update check
repository_id: None, // Will be populated during update check
repository_url: None, // Will be populated during update check

View File

@ -1,15 +1,17 @@
use tauri::{AppHandle, Emitter};
use futures::stream::StreamExt;
use crate::models::plugin::Plugin;
use crate::models::plugin::{Plugin, PluginCompatibilityStatus};
use crate::models::repository::{RepositorySource, RepositoryPlugin, BulkUpdateProgress, SingleUpdateResult};
use super::version_utils::compare_plugin_versions;
use crate::models::server::{ServerInfo, ServerType};
use super::version_utils::is_version_compatible;
use crate::platform_matcher::is_version_compatible_with_server;
/// Check for updates for multiple plugins
pub async fn check_for_plugin_updates(
app_handle: AppHandle,
installed_plugins: Vec<Plugin>,
repositories_to_check: Vec<RepositorySource>
repositories_to_check: Vec<RepositorySource>,
server_info: &ServerInfo,
) -> Result<Vec<Plugin>, 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<RepositorySource>,
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<Vec<String>>
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<String> = 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<String> {
// 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<char> = s1.chars().collect();
let s2_chars: Vec<char> = 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
}
}

View File

@ -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<String> {
// 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<i32> {
// 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::<i32>() {
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::<i32>().ok()).unwrap_or(0);
let n2 = v2_parts.get(i).and_then(|s| s.parse::<i32>().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<String> {
// Look for version pattern like 1.19.2 in string
@ -46,15 +293,34 @@ pub fn extract_version_pattern(input: &str) -> Option<String> {
}
/// 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<String>) -> 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
}

View File

@ -40,7 +40,7 @@
},
"plugins": {
"shell": {
"open": true
"open": "^((mailto:\\w+)|(tel:\\w+)|(https?://\\w+)|(file://.+)).+"
},
"dialog": null,
"fs": null

View File

@ -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 (
<ServerProvider>
<PluginContextWrapper />
<PluginContextWrapper appVersion={appVersion} />
</ServerProvider>
);
}
@ -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 (
<PluginProvider>
<UIProvider>
<AppContent />
<AppContent appVersion={appVersion} />
</UIProvider>
</PluginProvider>
);
@ -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() {
)}
</MainContent>
<Footer />
<Footer appVersion={appVersion} />
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { usePluginActions } from '../../../hooks/usePluginActions';
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
import { Plugin } from '../../../types/plugin.types';
@ -12,33 +12,149 @@ export const PluginList: React.FC = () => {
const [searchTerm, setSearchTerm] = useState<string>('');
const [sortBy, setSortBy] = useState<'name' | 'version' | 'update'>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [filterUpdates, setFilterUpdates] = useState<boolean>(false);
// Filter plugins based on search term
const filteredPlugins = plugins.filter(plugin =>
plugin.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
plugin.version.toLowerCase().includes(searchTerm.toLowerCase()) ||
(plugin.description && plugin.description.toLowerCase().includes(searchTerm.toLowerCase()))
);
// Sort plugins based on sort criteria
const sortedPlugins = [...filteredPlugins].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'version':
comparison = a.version.localeCompare(b.version);
break;
case 'update':
// Sort by update status (plugins with updates first)
comparison = (b.has_update ? 1 : 0) - (a.has_update ? 1 : 0);
break;
// Advanced search with fuzzy matching and relevance scoring
const filteredPlugins = useMemo(() => {
if (!searchTerm.trim() && !filterUpdates) {
return plugins;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
// Filter by updates if that filter is active
let results = filterUpdates ? plugins.filter(plugin => plugin.has_update) : plugins;
if (!searchTerm.trim()) {
return results;
}
const searchLower = searchTerm.toLowerCase().trim();
const searchTerms = searchLower.split(/\s+/);
// If using multiple search terms, match plugins that contain all terms
if (searchTerms.length > 1) {
return results.filter(plugin => {
// Create a searchable text combining all relevant plugin fields
const searchableText = [
plugin.name,
plugin.version,
plugin.description || '',
plugin.authors?.join(' ') || '',
plugin.website || '',
plugin.main_class || '',
].join(' ').toLowerCase();
// Plugin must match all search terms to be included
return searchTerms.every(term => searchableText.includes(term));
});
}
// For single term search, score each plugin for relevance
const scoredPlugins = results.map(plugin => {
let score = 0;
// Exact name match is highest priority
if (plugin.name.toLowerCase() === searchLower) {
score += 100;
}
// Name starts with search term
else if (plugin.name.toLowerCase().startsWith(searchLower)) {
score += 80;
}
// Name contains search term
else if (plugin.name.toLowerCase().includes(searchLower)) {
score += 60;
}
// Check if search term is an acronym of the plugin name
// E.g., "WE" for "WorldEdit"
if (searchLower.length >= 2 && isAcronymMatch(searchLower, plugin.name)) {
score += 70;
}
// Secondary matches in other fields
if (plugin.description?.toLowerCase().includes(searchLower)) {
score += 40;
}
if (plugin.authors?.some(author => author.toLowerCase().includes(searchLower))) {
score += 50;
}
if (plugin.main_class?.toLowerCase().includes(searchLower)) {
score += 30;
}
if (plugin.version.toLowerCase().includes(searchLower)) {
score += 20;
}
if (plugin.website?.toLowerCase().includes(searchLower)) {
score += 20;
}
// Tags or categories (if available in your data model)
if (plugin.repository_source?.toString().toLowerCase().includes(searchLower)) {
score += 30;
}
return { plugin, score };
});
// Filter plugins that have at least some relevance and sort by score
const relevantPlugins = scoredPlugins
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.map(item => item.plugin);
return relevantPlugins;
}, [plugins, searchTerm, filterUpdates]);
// Memoize sorted plugins for performance
const sortedPlugins = useMemo(() => {
return [...filteredPlugins].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'version':
comparison = a.version.localeCompare(b.version);
break;
case 'update':
// Sort by update status (plugins with updates first)
comparison = (b.has_update ? 1 : 0) - (a.has_update ? 1 : 0);
break;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
}, [filteredPlugins, sortBy, sortOrder]);
// Function to check if search is an acronym of plugin name
const isAcronymMatch = (acronym: string, fullName: string): boolean => {
// Extract capital letters or the first letter of each word
const words = fullName.split(/[\s-_]+/);
// Method 1: First letter of each word
const firstLetters = words.map(word => word.charAt(0).toLowerCase()).join('');
if (firstLetters === acronym) {
return true;
}
// Method 2: Capital letters in a camel case name
const capitals = fullName.replace(/[^A-Z]/g, '').toLowerCase();
if (capitals === acronym) {
return true;
}
// For shorter search terms, check if it's a substring of the first letters
if (acronym.length >= 2 && acronym.length <= 3 && firstLetters.includes(acronym)) {
return true;
}
return false;
};
const handleSort = (criteria: 'name' | 'version' | 'update') => {
if (criteria === sortBy) {
@ -51,64 +167,74 @@ export const PluginList: React.FC = () => {
}
};
const getSortIndicator = (criteria: 'name' | 'version' | 'update'): string => {
if (sortBy !== criteria) return '';
return sortOrder === 'asc' ? ' ↑' : ' ↓';
const toggleUpdateFilter = () => {
setFilterUpdates(!filterUpdates);
};
const clearFilters = () => {
setSearchTerm('');
setFilterUpdates(false);
};
// Get stats for UI feedback
const updateCount = plugins.filter(p => p.has_update).length;
return (
<div className="plugin-list-container">
<div className="search-container">
<input
type="text"
placeholder="Search plugins..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
{searchTerm && (
<button
className="clear-search"
onClick={() => setSearchTerm('')}
aria-label="Clear search"
>
×
</button>
)}
</div>
<div className="plugin-list-header">
<div className="search-and-filter">
<input
type="text"
className="search-input"
placeholder="Search plugins by name, author, description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="filter-options">
<Button
variant={filterUpdates ? "primary" : "secondary"}
size="small"
onClick={toggleUpdateFilter}
>
{filterUpdates ? "Showing Updates" : "Show Updates Only"}
{updateCount > 0 && `(${updateCount})`}
</Button>
{(searchTerm || filterUpdates) && (
<Button
variant="secondary"
size="small"
onClick={clearFilters}
>
Clear Filters
</Button>
)}
</div>
</div>
<div className="plugin-list-controls">
<div className="sort-controls">
<span className="sort-label">Sort by:</span>
<Button
variant="text"
variant={sortBy === 'name' ? 'primary' : 'secondary'}
size="small"
className={`sort-button ${sortBy === 'name' ? 'active' : ''}`}
onClick={() => handleSort('name')}
>
Name{getSortIndicator('name')}
Name {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
</Button>
<Button
variant="text"
variant={sortBy === 'version' ? 'primary' : 'secondary'}
size="small"
className={`sort-button ${sortBy === 'version' ? 'active' : ''}`}
onClick={() => handleSort('version')}
>
Version{getSortIndicator('version')}
Version {sortBy === 'version' && (sortOrder === 'asc' ? '↑' : '↓')}
</Button>
<Button
variant="text"
variant={sortBy === 'update' ? 'primary' : 'secondary'}
size="small"
className={`sort-button ${sortBy === 'update' ? 'active' : ''}`}
onClick={() => handleSort('update')}
>
Updates{getSortIndicator('update')}
Update Status {sortBy === 'update' && (sortOrder === 'asc' ? '↓' : '↑')}
</Button>
</div>
<div className="filter-info">
Showing {filteredPlugins.length} of {plugins.length} plugins
</div>
</div>
<div className="plugin-list">
@ -122,8 +248,8 @@ export const PluginList: React.FC = () => {
))
) : (
<div className="no-results">
<p>No plugins match your search.</p>
<Button variant="secondary" onClick={() => setSearchTerm('')}>Clear search</Button>
<p>No plugins match your search criteria.</p>
<Button variant="secondary" onClick={clearFilters}>Clear All Filters</Button>
</div>
)}
</div>

View File

@ -2,7 +2,7 @@ import React from 'react';
import { ServerInfo as ServerInfoType } from '../../../types/server.types';
import Badge from '../../common/Badge/Badge';
import './ServerInfo.css';
// Import only shell open as it's the primary method
// Use the shell.open for opening directories
import { open } from '@tauri-apps/plugin-shell';
import { useServerContext } from '../../../context/ServerContext/useServerContext';
@ -58,13 +58,30 @@ export const ServerInfo: React.FC<ServerInfoProps> = ({ serverInfo }) => {
// Function to open the server directory in file explorer
const handleOpenDirectory = async () => {
try {
console.log('Attempting to open directory using shell.open:', serverPath);
// Use shell.open directly - it's generally more robust, especially for UNC
await open(serverPath);
console.log('Successfully opened directory via shell.open');
console.log('Attempting to open directory:', serverPath);
// Format the path as a proper file:// URL
let dirPath = serverPath;
// Convert to the proper URL format based on path type
if (dirPath.startsWith('\\\\')) {
// UNC path (network location)
// Format: file:////server/share/path (4 slashes)
dirPath = `file:////` + dirPath.replace(/\\/g, '/').substring(2);
} else {
// Local path
// Format: file:///C:/path/to/dir (3 slashes)
dirPath = `file:///${dirPath.replace(/\\/g, '/')}`;
}
console.log('Opening directory with path:', dirPath);
// Open the directory in system's file explorer
await open(dirPath);
console.log('Successfully opened directory');
} catch (error) {
console.error('Failed to open directory:', error);
// Optionally add user feedback here (e.g., toast notification)
}
};

View File

@ -261,6 +261,7 @@ export const PluginProvider: React.FC<PluginProviderProps> = ({
const updatedPlugins = await invoke<Plugin[]>("check_plugin_updates", {
plugins: pluginsToSend,
repositories: repositoriesToCheck,
serverPath: currentServerPath
});
console.log("Bulk update check completed successfully, updating state.");