Enhance app version management and improve plugin list filtering with advanced search capabilities
This commit is contained in:
parent
61becf8d22
commit
0240ab9c50
@ -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",
|
||||
|
@ -1,2 +1,3 @@
|
||||
pub mod plugin_commands;
|
||||
pub mod scan_commands;
|
||||
pub mod util_commands;
|
@ -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
|
||||
|
17
src-tauri/src/commands/util_commands.rs
Normal file
17
src-tauri/src/commands/util_commands.rs
Normal 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(),
|
||||
}
|
||||
}
|
@ -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))
|
||||
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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!())
|
||||
|
@ -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>,
|
||||
|
@ -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
|
||||
|
@ -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
@ -3,34 +3,281 @@ 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
|
||||
@ -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
|
||||
}
|
@ -40,7 +40,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
"open": "^((mailto:\\w+)|(tel:\\w+)|(https?://\\w+)|(file://.+)).+"
|
||||
},
|
||||
"dialog": null,
|
||||
"fs": null
|
||||
|
40
src/App.tsx
40
src/App.tsx
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import './App.css';
|
||||
|
||||
// Import context providers
|
||||
@ -38,6 +38,15 @@ import { useServerContext } from './context/ServerContext/useServerContext';
|
||||
import { usePluginContext } from './context/PluginContext/usePluginContext';
|
||||
import { usePluginActions } from './hooks/usePluginActions';
|
||||
|
||||
// Define interfaces for component props
|
||||
interface PluginContextWrapperProps {
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
interface AppContentProps {
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main application component that serves as the entry point.
|
||||
* This component is responsible for setting up the context providers
|
||||
@ -45,10 +54,29 @@ import { usePluginActions } from './hooks/usePluginActions';
|
||||
*/
|
||||
function App() {
|
||||
console.log("App Component Initialized");
|
||||
const [appVersion, setAppVersion] = useState('1.0.0');
|
||||
|
||||
useEffect(() => {
|
||||
// Get the app version from the backend
|
||||
const getAppVersion = async () => {
|
||||
try {
|
||||
// Use Tauri's invoke to call our Rust command
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const appInfo = await invoke('get_app_version');
|
||||
setAppVersion((appInfo as { version: string }).version);
|
||||
} catch (error) {
|
||||
console.error('Failed to get app version:', error);
|
||||
// display unknown version
|
||||
setAppVersion('unknown');
|
||||
}
|
||||
};
|
||||
|
||||
getAppVersion();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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.");
|
||||
|
Loading…
Reference in New Issue
Block a user