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

File diff suppressed because it is too large Load Diff

View File

@ -3,35 +3,282 @@ 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> {
@ -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 {
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) = Version::parse(minecraft_version) {
if let Ok(mc_version) = semver::Version::parse(plugin_version) {
// Try to parse as a version requirement
if let Ok(req) = VersionReq::parse(plugin_version) {
return req.matches(&mc_version);
if let Ok(req) = semver::VersionReq::parse(supported_version) {
if req.matches(&mc_version) {
return true;
}
}
}
// If version formats are incompatible, make best guess
plugin_version.contains(minecraft_version)
if plugin_version.contains(supported_version) || supported_version.contains(plugin_version) {
return true;
}
}
// 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,16 +12,106 @@ 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()))
);
// Advanced search with fuzzy matching and relevance scoring
const filteredPlugins = useMemo(() => {
if (!searchTerm.trim() && !filterUpdates) {
return plugins;
}
// Sort plugins based on sort criteria
const sortedPlugins = [...filteredPlugins].sort((a, b) => {
// 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) {
@ -39,6 +129,32 @@ export const PluginList: React.FC = () => {
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">
<div className="plugin-list-header">
<div className="search-and-filter">
<input
type="text"
placeholder="Search plugins..."
className="search-input"
placeholder="Search plugins by name, author, description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
{searchTerm && (
<button
className="clear-search"
onClick={() => setSearchTerm('')}
aria-label="Clear search"
<div className="filter-options">
<Button
variant={filterUpdates ? "primary" : "secondary"}
size="small"
onClick={toggleUpdateFilter}
>
×
</button>
{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.");