use crate::db; use crate::models::{DirEntry, SourceCategory, SourceProject, SourceVersion}; use std::fs; use std::path::Path; use uuid::Uuid; // ── 分类 CRUD ──────────────────────────────────────────────────────────────── #[tauri::command] pub fn get_source_categories() -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare("SELECT id, name, icon, color, sort_order FROM source_categories ORDER BY sort_order, name") .map_err(|e| e.to_string())?; let rows = stmt .query_map([], SourceCategory::from_row) .map_err(|e| e.to_string())?; rows.map(|r| r.map_err(|e| e.to_string())).collect() } #[tauri::command] pub fn create_source_category(name: String, icon: Option, color: Option) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let id = Uuid::new_v4().to_string(); let icon = icon.unwrap_or_else(|| "📁".into()); let color = color.unwrap_or_else(|| "#6B7280".into()); conn.execute( "INSERT INTO source_categories (id, name, icon, color) VALUES (?1, ?2, ?3, ?4)", rusqlite::params![id, name, icon, color], ).map_err(|e| e.to_string())?; Ok(SourceCategory { id, name, icon, color, sort_order: 0 }) } #[tauri::command] pub fn update_source_category(id: String, name: String, icon: String, color: String) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; conn.execute( "UPDATE source_categories SET name=?2, icon=?3, color=?4 WHERE id=?1", rusqlite::params![id, name, icon, color], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_source_category(id: String) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; conn.execute("UPDATE source_projects SET category_id=NULL WHERE category_id=?1", rusqlite::params![id]) .map_err(|e| e.to_string())?; conn.execute("DELETE FROM source_categories WHERE id=?1", rusqlite::params![id]) .map_err(|e| e.to_string())?; Ok(()) } // ── 项目 CRUD ──────────────────────────────────────────────────────────────── #[tauri::command] pub fn get_source_projects(category_id: Option, search: Option) -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut sql = String::from( "SELECT p.*, c.name AS category_name, (SELECT COUNT(*) FROM source_versions v WHERE v.project_id = p.id) AS version_count FROM source_projects p LEFT JOIN source_categories c ON c.id = p.category_id WHERE 1=1" ); let mut params: Vec> = vec![]; if let Some(ref cid) = category_id { sql.push_str(" AND p.category_id = ?"); params.push(Box::new(cid.clone())); } if let Some(ref q) = search { sql.push_str(" AND (p.name LIKE ? OR p.description LIKE ? OR p.language LIKE ?)"); let like = format!("%{}%", q); params.push(Box::new(like.clone())); params.push(Box::new(like.clone())); params.push(Box::new(like)); } sql.push_str(" ORDER BY p.updated_at DESC"); let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let rows = stmt .query_map(param_refs.as_slice(), SourceProject::from_row) .map_err(|e| e.to_string())?; rows.map(|r| r.map_err(|e| e.to_string())).collect() } #[tauri::command] pub fn create_source_project( name: String, description: Option, category_id: Option, language: Option, repo_url: Option, introduction: Option, ) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let id = Uuid::new_v4().to_string(); conn.execute( "INSERT INTO source_projects (id, name, description, category_id, language, repo_url, introduction) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![id, name, description, category_id, language, repo_url, introduction], ).map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT p.*, c.name AS category_name, 0 AS version_count FROM source_projects p LEFT JOIN source_categories c ON c.id = p.category_id WHERE p.id = ?1" ).map_err(|e| e.to_string())?; stmt.query_row(rusqlite::params![id], SourceProject::from_row) .map_err(|e| e.to_string()) } #[tauri::command] pub fn update_source_project( id: String, name: String, description: Option, category_id: Option, language: Option, repo_url: Option, introduction: Option, notes: Option, ) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; conn.execute( "UPDATE source_projects SET name=?2, description=?3, category_id=?4, language=?5, repo_url=?6, introduction=?7, notes=?8, updated_at=datetime('now') WHERE id=?1", rusqlite::params![id, name, description, category_id, language, repo_url, introduction, notes], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_source_project(id: String) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; // 先获取所有版本的文件路径,清理托管目录中的文件 let mut stmt = conn.prepare("SELECT file_path FROM source_versions WHERE project_id=?1") .map_err(|e| e.to_string())?; let paths: Vec = stmt.query_map(rusqlite::params![id], |r| r.get(0)) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); for p in &paths { let path = Path::new(p); if path.is_dir() { let _ = fs::remove_dir_all(path); } else if path.is_file() { let _ = fs::remove_file(path); } } // CASCADE 会删除 source_versions conn.execute("DELETE FROM source_projects WHERE id=?1", rusqlite::params![id]) .map_err(|e| e.to_string())?; Ok(()) } // ── 版本管理 ───────────────────────────────────────────────────────────────── #[tauri::command] pub fn get_source_versions(project_id: String) -> Result, String> { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare("SELECT * FROM source_versions WHERE project_id=?1 ORDER BY is_primary DESC, imported_at DESC") .map_err(|e| e.to_string())?; let rows = stmt .query_map(rusqlite::params![project_id], SourceVersion::from_row) .map_err(|e| e.to_string())?; rows.map(|r| r.map_err(|e| e.to_string())).collect() } /// 导入一个版本——支持两种模式: /// 1. 目录模式:file_path 指向已有源码目录,原地引用 /// 2. 文件模式:file_path 指向 .zip/.tar.gz 等压缩包,复制到托管目录保管 #[tauri::command] pub fn add_source_version( app_handle: tauri::AppHandle, project_id: String, project_name: String, version: String, file_path: String, ) -> Result { use tauri::Manager; let conn = db::pool().get().map_err(|e| e.to_string())?; let id = Uuid::new_v4().to_string(); let src = Path::new(&file_path); if !src.exists() { return Err(format!("路径不存在: {}", file_path)); } let is_dir = src.is_dir(); let (stored_path, file_size, dir_tree_json, archive_path); if is_dir { // ── 目录模式:原地引用,扫描目录树 ── stored_path = file_path.clone(); file_size = calc_dir_size(&file_path); let dir_tree = scan_dir_tree_internal(&file_path); dir_tree_json = serde_json::to_string(&dir_tree).unwrap_or_else(|_| "[]".into()); archive_path = None::; // 自动提取 README if let Some(ref readme_content) = extract_readme_internal(&file_path) { let _ = conn.execute( "UPDATE source_projects SET readme=?2, updated_at=datetime('now') WHERE id=?1 AND (readme IS NULL OR readme='')", rusqlite::params![project_id, readme_content], ); } // 自动检测语言 if let Some(ref lang) = detect_language_internal(&file_path) { let _ = conn.execute( "UPDATE source_projects SET language=?2 WHERE id=?1 AND (language IS NULL OR language='')", rusqlite::params![project_id, lang], ); } } else { // ── 文件模式:复制压缩包到托管目录 ── let data_dir = app_handle.path().app_data_dir().map_err(|e| e.to_string())?; let lib_root = data_dir.join("source-library"); let safe_name: String = project_name.chars() .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }) .collect(); let dest_dir = lib_root.join(&safe_name); fs::create_dir_all(&dest_dir).map_err(|e| format!("创建托管目录失败: {}", e))?; let file_name = src.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| format!("{}.archive", version)); let dest_file = dest_dir.join(&file_name); // 复制文件到托管目录 fs::copy(src, &dest_file).map_err(|e| format!("复制文件失败: {}", e))?; file_size = dest_file.metadata().map(|m| m.len() as i64).unwrap_or(0); stored_path = dest_file.to_string_lossy().to_string(); archive_path = Some(file_path.clone()); // 记录原始路径 // 文件模式的目录树:显示压缩包文件名本身 let entry = DirEntry { name: file_name, type_: "file".into(), children_count: None, }; dir_tree_json = serde_json::to_string(&vec![entry]).unwrap_or_else(|_| "[]".into()); } // 检查是否为首个版本 let count: i64 = conn .query_row("SELECT COUNT(*) FROM source_versions WHERE project_id=?1", rusqlite::params![project_id], |r| r.get(0)) .unwrap_or(0); let is_primary = if count == 0 { 1 } else { 0 }; conn.execute( "INSERT INTO source_versions (id, project_id, version, file_path, archive_path, file_size, is_primary, dir_tree) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![id, project_id, version, stored_path, archive_path, file_size, is_primary, dir_tree_json], ).map_err(|e| e.to_string())?; // 更新项目 updated_at let _ = conn.execute("UPDATE source_projects SET updated_at=datetime('now') WHERE id=?1", rusqlite::params![project_id]); Ok(SourceVersion { id, project_id, version, file_path: stored_path, archive_path, file_size, is_primary: is_primary != 0, dir_tree: Some(dir_tree_json), notes: None, imported_at: None, }) } #[tauri::command] pub fn set_primary_version(version_id: String, project_id: String) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; conn.execute("UPDATE source_versions SET is_primary=0 WHERE project_id=?1", rusqlite::params![project_id]) .map_err(|e| e.to_string())?; conn.execute("UPDATE source_versions SET is_primary=1 WHERE id=?1", rusqlite::params![version_id]) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_source_version(version_id: String) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; let path: String = conn .query_row("SELECT file_path FROM source_versions WHERE id=?1", rusqlite::params![version_id], |r| r.get(0)) .map_err(|e| e.to_string())?; let p = Path::new(&path); if p.is_dir() { let _ = fs::remove_dir_all(p); } else if p.is_file() { let _ = fs::remove_file(p); } conn.execute("DELETE FROM source_versions WHERE id=?1", rusqlite::params![version_id]) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn update_version_notes(version_id: String, notes: String) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; conn.execute("UPDATE source_versions SET notes=?2 WHERE id=?1", rusqlite::params![version_id, notes]) .map_err(|e| e.to_string())?; Ok(()) } /// 在文件管理器中打开文件/目录所在位置 #[tauri::command] pub fn reveal_source_in_explorer(path: String) -> Result<(), String> { let p = Path::new(&path); if !p.exists() { return Err(format!("路径不存在: {}", path)); } // 如果是文件,打开其父目录并选中;如果是目录,直接打开 #[cfg(target_os = "windows")] { if p.is_file() { // explorer /select,"path" 会打开并选中文件 let _ = std::process::Command::new("explorer") .arg("/select,") .arg(&path) .spawn(); } else { let _ = std::process::Command::new("explorer") .arg(&path) .spawn(); } } #[cfg(target_os = "macos")] { if p.is_file() { let _ = std::process::Command::new("open").arg("-R").arg(&path).spawn(); } else { let _ = std::process::Command::new("open").arg(&path).spawn(); } } #[cfg(target_os = "linux")] { let dir = if p.is_file() { p.parent().unwrap_or(p) } else { p }; let _ = std::process::Command::new("xdg-open").arg(dir).spawn(); } Ok(()) } /// 复制文件/目录到指定目标路径 #[tauri::command] pub fn copy_source_to(source: String, dest_dir: String) -> Result { let src = Path::new(&source); if !src.exists() { return Err(format!("源路径不存在: {}", source)); } let dest = Path::new(&dest_dir); fs::create_dir_all(dest).map_err(|e| format!("创建目标目录失败: {}", e))?; if src.is_file() { let file_name = src.file_name().ok_or("无法获取文件名")?; let dest_file = dest.join(file_name); fs::copy(src, &dest_file).map_err(|e| format!("复制文件失败: {}", e))?; Ok(dest_file.to_string_lossy().to_string()) } else { // 复制整个目录 let dir_name = src.file_name().ok_or("无法获取目录名")?; let dest_sub = dest.join(dir_name); copy_dir_recursive(src, &dest_sub)?; Ok(dest_sub.to_string_lossy().to_string()) } } fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> { fs::create_dir_all(dest).map_err(|e| format!("创建目录失败: {}", e))?; for entry in fs::read_dir(src).map_err(|e| format!("读取目录失败: {}", e))? { let entry = entry.map_err(|e| e.to_string())?; let src_path = entry.path(); let dest_path = dest.join(entry.file_name()); if src_path.is_dir() { copy_dir_recursive(&src_path, &dest_path)?; } else { fs::copy(&src_path, &dest_path).map_err(|e| format!("复制失败: {}", e))?; } } Ok(()) } // ── 目录树扫描 ─────────────────────────────────────────────────────────────── #[tauri::command] pub fn scan_source_dir_tree(path: String) -> Result, String> { Ok(scan_dir_tree_internal(&path)) } fn scan_dir_tree_internal(path: &str) -> Vec { let root = Path::new(path); if !root.is_dir() { return vec![]; } let ignore_dirs = [ "node_modules", ".git", "target", "vendor", "__pycache__", ".next", ".nuxt", "dist", "build", ".idea", ".vscode", "venv", ".venv", "env", ]; let mut entries: Vec = Vec::new(); if let Ok(read_dir) = fs::read_dir(root) { let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect(); items.sort_by(|a, b| { let a_dir = a.path().is_dir(); let b_dir = b.path().is_dir(); b_dir.cmp(&a_dir).then_with(|| a.file_name().cmp(&b.file_name())) }); for entry in items { let name = entry.file_name().to_string_lossy().to_string(); let path = entry.path(); if path.is_dir() { if ignore_dirs.contains(&name.as_str()) { continue; } let children_count = fs::read_dir(&path) .map(|rd| rd.count() as i32) .unwrap_or(0); entries.push(DirEntry { name: format!("{}/", name), type_: "dir".into(), children_count: Some(children_count), }); } else { entries.push(DirEntry { name, type_: "file".into(), children_count: None, }); } } } entries } // ── README 提取 ────────────────────────────────────────────────────────────── fn extract_readme_internal(path: &str) -> Option { let root = Path::new(path); let candidates = ["README.md", "readme.md", "README.MD", "Readme.md", "README", "readme"]; for name in &candidates { let file = root.join(name); if file.is_file() { if let Ok(content) = fs::read_to_string(&file) { if content.len() > 50 * 1024 { return Some(content[..50 * 1024].to_string()); } return Some(content); } } } None } // ── 语言检测 ───────────────────────────────────────────────────────────────── fn detect_language_internal(path: &str) -> Option { let root = Path::new(path); let checks: &[(&str, &str)] = &[ ("Cargo.toml", "Rust"), ("go.mod", "Go"), ("package.json", "TypeScript"), ("pom.xml", "Java"), ("build.gradle", "Java"), ("build.gradle.kts", "Kotlin"), ("requirements.txt", "Python"), ("setup.py", "Python"), ("pyproject.toml", "Python"), ("Gemfile", "Ruby"), ("composer.json", "PHP"), ("Package.swift", "Swift"), ("CMakeLists.txt", "C++"), ("Makefile", "C"), ]; for (file, lang) in checks { if root.join(file).exists() { if *file == "package.json" { if root.join("tsconfig.json").exists() { return Some("TypeScript".into()); } return Some("JavaScript".into()); } return Some(lang.to_string()); } } None } // ── 工具函数 ───────────────────────────────────────────────────────────────── fn calc_dir_size(path: &str) -> i64 { let mut total: u64 = 0; if let Ok(entries) = walkdir(Path::new(path)) { total = entries; } total as i64 } fn walkdir(path: &Path) -> Result { let mut size = 0u64; if path.is_dir() { if let Ok(rd) = fs::read_dir(path) { for entry in rd.flatten() { let p = entry.path(); if p.is_dir() { size += walkdir(&p).unwrap_or(0); } else if let Ok(meta) = p.metadata() { size += meta.len(); } } } } Ok(size) }