345 lines
11 KiB
Rust
345 lines
11 KiB
Rust
//! 快速面板:基于 7-Zip(binaries/7z.exe)的批量文件操作。
|
||
//!
|
||
//! - 批量解压:逐文件调用 7z 控制台,支持一次传入统一密码(`-p`),
|
||
//! 每完成一个文件通过事件推送进度,前端可实时展示。
|
||
//! - 批量重命名:`regex` 匹配文件名生成预览,确认后执行 `fs::rename`。
|
||
//! - 目录列表:供前端展示当前目录的压缩包 / 文件列表。
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::Command;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use specta::Type;
|
||
use tauri::{AppHandle, Emitter, Manager};
|
||
|
||
use crate::constants::events::QUICKPANEL_EXTRACT_PROGRESS;
|
||
|
||
/// 7z 可执行文件名(与 7z.dll 同目录,位于 resources/binaries)。
|
||
const _7Z_EXE: &str = "binaries/7z.exe";
|
||
|
||
/// 支持的压缩包扩展名(解压面板中列出)。
|
||
const ARCHIVE_EXTS: &[&str] = &[
|
||
".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".xz", ".txz", ".zst",
|
||
".tzst", ".cab", ".lzma",
|
||
];
|
||
|
||
/// 定位 7z 可执行文件(resources/binaries/7z.exe)。
|
||
fn locate_7z(app: &AppHandle) -> Option<PathBuf> {
|
||
let path = app
|
||
.path()
|
||
.resolve(_7Z_EXE, tauri::path::BaseDirectory::Resource)
|
||
.ok()?;
|
||
if path.exists() {
|
||
Some(path)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ArchiveInfo {
|
||
pub name: String,
|
||
pub path: String,
|
||
pub size: u64,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct FileEntry {
|
||
pub name: String,
|
||
pub path: String,
|
||
pub is_dir: bool,
|
||
pub size: u64,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ExtractResult {
|
||
pub name: String,
|
||
pub path: String,
|
||
pub ok: bool,
|
||
pub error: String,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ExtractProgress {
|
||
pub done: usize,
|
||
pub total: usize,
|
||
pub current: String,
|
||
pub ok: bool,
|
||
pub error: String,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct RenamePreview {
|
||
pub path: String,
|
||
pub old_name: String,
|
||
pub new_name: String,
|
||
pub error: String,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct RenameItem {
|
||
pub path: String,
|
||
pub old_name: String,
|
||
pub new_name: String,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct RenameResult {
|
||
pub old_name: String,
|
||
pub new_name: String,
|
||
pub ok: bool,
|
||
pub error: String,
|
||
}
|
||
|
||
fn is_archive(name: &str) -> bool {
|
||
let lower = name.to_lowercase();
|
||
ARCHIVE_EXTS.iter().any(|ext| lower.ends_with(ext))
|
||
}
|
||
|
||
/// 列出目录下的压缩包文件(供批量解压面板使用)。
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn quickpanel_list_archives(dir: String) -> Result<Vec<ArchiveInfo>, String> {
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
let d = Path::new(&dir);
|
||
if !d.is_dir() {
|
||
return Err(format!("目录不存在: {dir}"));
|
||
}
|
||
let mut out = Vec::new();
|
||
for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? {
|
||
let Ok(entry) = entry else { continue };
|
||
let path = entry.path();
|
||
if !path.is_file() {
|
||
continue;
|
||
}
|
||
let name = path
|
||
.file_name()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_default();
|
||
if is_archive(&name) {
|
||
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
|
||
out.push(ArchiveInfo {
|
||
name,
|
||
path: path.to_string_lossy().to_string(),
|
||
size,
|
||
});
|
||
}
|
||
}
|
||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||
Ok(out)
|
||
})
|
||
.await
|
||
.map_err(|e| format!("读取压缩包失败: {e}"))?
|
||
}
|
||
|
||
/// 列出目录下的全部条目(供批量重命名/删除面板使用,不含子目录递归)。
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn quickpanel_list_dir(dir: String) -> Result<Vec<FileEntry>, String> {
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
let d = Path::new(&dir);
|
||
if !d.is_dir() {
|
||
return Err(format!("目录不存在: {dir}"));
|
||
}
|
||
let mut out = Vec::new();
|
||
for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? {
|
||
let Ok(entry) = entry else { continue };
|
||
let path = entry.path();
|
||
let is_dir = path.is_dir();
|
||
let name = path
|
||
.file_name()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_default();
|
||
let size = if is_dir {
|
||
0
|
||
} else {
|
||
path.metadata().map(|m| m.len()).unwrap_or(0)
|
||
};
|
||
out.push(FileEntry {
|
||
name,
|
||
path: path.to_string_lossy().to_string(),
|
||
is_dir,
|
||
size,
|
||
});
|
||
}
|
||
out.sort_by(|a, b| {
|
||
b.is_dir
|
||
.cmp(&a.is_dir)
|
||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||
});
|
||
Ok(out)
|
||
})
|
||
.await
|
||
.map_err(|e| format!("读取目录失败: {e}"))?
|
||
}
|
||
|
||
fn extract_one(
|
||
exe: &Path,
|
||
archive: &str,
|
||
dest: &str,
|
||
password: Option<&str>,
|
||
into_subfolder: bool,
|
||
) -> Result<(), String> {
|
||
let archive_path = Path::new(archive);
|
||
let mut cmd = Command::new(exe);
|
||
cmd.arg("x").arg(archive_path);
|
||
if into_subfolder {
|
||
// 解压到「压缩包同名子文件夹」,避免文件散落在当前目录。
|
||
let sub = archive_path
|
||
.file_stem()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| "extracted".into());
|
||
let sub_dir = Path::new(dest).join(&sub);
|
||
std::fs::create_dir_all(&sub_dir).map_err(|e| format!("创建目录 {sub} 失败: {e}"))?;
|
||
cmd.arg(format!("-o{}", sub_dir.display()));
|
||
} else {
|
||
cmd.arg(format!("-o{}", dest));
|
||
}
|
||
cmd.arg("-y"); // 全自动覆盖确认
|
||
if let Some(p) = password.filter(|p| !p.is_empty()) {
|
||
cmd.arg(format!("-p{p}"));
|
||
}
|
||
// 静默普通输出,仅错误进 stderr,逐文件粒度足够时无需 -bsp1 进度。
|
||
cmd.arg("-bso0").arg("-bse1").arg("-bsp0");
|
||
// 以 7z.exe 所在目录为工作目录,确保同目录的 7z.dll 可被加载。
|
||
if let Some(dir) = exe.parent() {
|
||
cmd.current_dir(dir);
|
||
}
|
||
let output = cmd.output().map_err(|e| format!("启动 7-Zip 失败: {e}"))?;
|
||
if output.status.success() {
|
||
Ok(())
|
||
} else {
|
||
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||
let code = output.status.code().unwrap_or(-1);
|
||
Err(if msg.is_empty() {
|
||
format!("退出码 {code}")
|
||
} else {
|
||
msg
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 批量解压。`files` 为压缩包路径列表,`dest_dir` 为目标目录,
|
||
/// `password` 为统一解压密码(可空),`into_subfolder` 是否解压到同名子文件夹。
|
||
/// 每完成一个文件通过 `quickpanel-extract-progress` 事件推送进度。
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn quickpanel_batch_extract(
|
||
app: AppHandle,
|
||
files: Vec<String>,
|
||
dest_dir: String,
|
||
password: Option<String>,
|
||
into_subfolder: bool,
|
||
) -> Result<Vec<ExtractResult>, String> {
|
||
let exe = locate_7z(&app).ok_or("未找到 7-Zip 组件(binaries/7z.exe),请重新安装")?;
|
||
let total = files.len();
|
||
let results = tauri::async_runtime::spawn_blocking(move || {
|
||
let mut results = Vec::with_capacity(total);
|
||
for (idx, file) in files.iter().enumerate() {
|
||
let err = extract_one(&exe, file, &dest_dir, password.as_deref(), into_subfolder);
|
||
let name = Path::new(file)
|
||
.file_name()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_else(|| file.clone());
|
||
let (ok, error) = match err {
|
||
Ok(()) => (true, String::new()),
|
||
Err(e) => (false, e),
|
||
};
|
||
results.push(ExtractResult {
|
||
name,
|
||
path: file.clone(),
|
||
ok,
|
||
error: error.clone(),
|
||
});
|
||
let _ = app.emit(
|
||
QUICKPANEL_EXTRACT_PROGRESS,
|
||
ExtractProgress {
|
||
done: idx + 1,
|
||
total,
|
||
current: results[idx].name.clone(),
|
||
ok,
|
||
error,
|
||
},
|
||
);
|
||
}
|
||
results
|
||
})
|
||
.await
|
||
.map_err(|e| format!("解压任务异常终止: {e}"))?;
|
||
Ok(results)
|
||
}
|
||
|
||
/// 正则批量重命名预览:对每个文件名应用 `pattern → replacement`,
|
||
/// 仅返回有匹配的文件,`newName` 为替换结果。
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn quickpanel_preview_rename(
|
||
files: Vec<String>,
|
||
pattern: String,
|
||
replacement: String,
|
||
) -> Result<Vec<RenamePreview>, String> {
|
||
let re = regex::Regex::new(&pattern).map_err(|e| format!("正则表达式无效: {e}"))?;
|
||
let mut out = Vec::new();
|
||
for file in files {
|
||
let name = Path::new(&file)
|
||
.file_name()
|
||
.map(|s| s.to_string_lossy().to_string())
|
||
.unwrap_or_default();
|
||
if !re.is_match(&name) {
|
||
continue;
|
||
}
|
||
let new_name = re.replace_all(&name, replacement.as_str()).to_string();
|
||
let error = if new_name.is_empty() || new_name == name {
|
||
"名称未变化".to_string()
|
||
} else {
|
||
String::new()
|
||
};
|
||
out.push(RenamePreview {
|
||
path: file,
|
||
old_name: name,
|
||
new_name,
|
||
error,
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// 执行重命名。同一目录下若目标已存在则跳过该项。
|
||
#[tauri::command]
|
||
#[specta::specta]
|
||
pub async fn quickpanel_apply_rename(items: Vec<RenameItem>) -> Result<Vec<RenameResult>, String> {
|
||
let mut out = Vec::new();
|
||
for item in items {
|
||
let result = (|| -> Result<(), String> {
|
||
if item.new_name.is_empty() {
|
||
return Err("新文件名为空".into());
|
||
}
|
||
if item.new_name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||
return Err("文件名包含非法字符".into());
|
||
}
|
||
let old = Path::new(&item.path);
|
||
let parent = old.parent().unwrap_or(Path::new("."));
|
||
let new_path = parent.join(&item.new_name);
|
||
if new_path.exists() {
|
||
return Err("目标已存在".into());
|
||
}
|
||
std::fs::rename(old, &new_path).map_err(|e| format!("重命名失败: {e}"))?;
|
||
Ok(())
|
||
})();
|
||
out.push(RenameResult {
|
||
old_name: item.old_name,
|
||
new_name: item.new_name,
|
||
ok: result.is_ok(),
|
||
error: result.err().unwrap_or_default(),
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|