Feat: 修复 Rust 部分异常,支持通过目录读取文件内容
This commit is contained in:
Generated
+68
@@ -84,7 +84,9 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -666,6 +668,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -2757,6 +2761,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rkyv"
|
||||
version = "0.7.46"
|
||||
@@ -3501,6 +3529,46 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.17",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.17",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
|
||||
@@ -21,5 +21,7 @@ tauri-build = { version = "2.5.3", features = [] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
walkdir = "2.5"
|
||||
tauri = { version = "2.9.5", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-dialog = "2.6.0"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
"core:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
mod sources;
|
||||
|
||||
use sources::{load_project_sources, register_directory_sources, save_language_file, scan_language_directory};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
scan_language_directory,
|
||||
register_directory_sources,
|
||||
load_project_sources,
|
||||
save_language_file
|
||||
])
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
@@ -11,6 +21,7 @@ pub fn run() {
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SourceMode {
|
||||
Directory,
|
||||
Flat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DirectorySourceConfig {
|
||||
pub base_dir: String,
|
||||
pub default_language: String,
|
||||
pub relative_path: String,
|
||||
pub files: BTreeMap<String, String>, // language -> absolute path
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlatSourceConfig {
|
||||
pub placeholder: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||
pub enum ProjectSourceConfig {
|
||||
Directory(DirectorySourceConfig),
|
||||
Flat(FlatSourceConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProjectsConfig {
|
||||
pub projects: BTreeMap<String, ProjectSourceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScanLanguageDirectoryResult {
|
||||
pub base_dir: String,
|
||||
pub languages: Vec<LanguageFolderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LanguageFolderSummary {
|
||||
pub language: String,
|
||||
pub file_count: usize,
|
||||
pub files: Vec<LanguageFileSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LanguageFileSummary {
|
||||
pub file_name: String,
|
||||
pub relative_path: String,
|
||||
pub absolute_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterDirectorySourcesPayload {
|
||||
pub project_id: String,
|
||||
pub base_dir: String,
|
||||
pub default_language: String,
|
||||
pub relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoadedProjectSources {
|
||||
pub project_id: String,
|
||||
pub mode: SourceMode,
|
||||
pub base_dir: Option<String>,
|
||||
pub default_language: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
pub languages: Vec<LoadedLanguageFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoadedLanguageFile {
|
||||
pub language: String,
|
||||
pub path: String,
|
||||
pub exists: bool,
|
||||
pub modified_ms: Option<i64>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SaveLanguageFilePayload {
|
||||
pub project_id: String,
|
||||
pub language: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn scan_language_directory(dir_path: String) -> Result<ScanLanguageDirectoryResult, String> {
|
||||
let base_path = PathBuf::from(dir_path.trim());
|
||||
if !base_path.is_dir() {
|
||||
return Err("目录不存在或不可访问".into());
|
||||
}
|
||||
let canonical = canonical_string(&base_path)?;
|
||||
let languages = collect_language_folders(&base_path)?;
|
||||
if languages.is_empty() {
|
||||
return Err("该目录下未找到任何语言子目录或 JSON 文件".into());
|
||||
}
|
||||
Ok(ScanLanguageDirectoryResult {
|
||||
base_dir: canonical,
|
||||
languages,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn register_directory_sources(app: AppHandle, payload: RegisterDirectorySourcesPayload) -> Result<ProjectSourceConfig, String> {
|
||||
let project_id = payload.project_id.trim();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".into());
|
||||
}
|
||||
let base_dir = PathBuf::from(payload.base_dir.trim());
|
||||
if !base_dir.is_dir() {
|
||||
return Err("基础目录不存在或不可访问".into());
|
||||
}
|
||||
let canonical_base = canonical_string(&base_dir)?;
|
||||
let default_language = payload.default_language.trim();
|
||||
if default_language.is_empty() {
|
||||
return Err("默认语言不能为空".into());
|
||||
}
|
||||
let relative_path = payload.relative_path.trim();
|
||||
if relative_path.is_empty() {
|
||||
return Err("语言包路径不能为空".into());
|
||||
}
|
||||
|
||||
let default_dir = base_dir.join(default_language);
|
||||
if !default_dir.is_dir() {
|
||||
return Err("默认语言目录不存在".into());
|
||||
}
|
||||
let default_file = default_dir.join(relative_path);
|
||||
if !default_file.is_file() {
|
||||
return Err(format!("默认语言文件不存在:{}", default_file.display()));
|
||||
}
|
||||
|
||||
let mut files = BTreeMap::new();
|
||||
for entry in list_language_dirs(&base_dir)? {
|
||||
let language = entry.file_name().to_string_lossy().trim().to_string();
|
||||
if language.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let candidate = entry.path().join(relative_path);
|
||||
if candidate.is_file() {
|
||||
let canonical = canonical_string(&candidate)?;
|
||||
files.insert(language, canonical);
|
||||
}
|
||||
}
|
||||
|
||||
if files.is_empty() {
|
||||
return Err("未找到任何可用的语言包文件,请检查目录结构".into());
|
||||
}
|
||||
|
||||
let config = DirectorySourceConfig {
|
||||
base_dir: canonical_base,
|
||||
default_language: default_language.to_string(),
|
||||
relative_path: relative_path.to_string(),
|
||||
files,
|
||||
updated_at: now_ms(),
|
||||
};
|
||||
|
||||
let mut projects = load_projects_config(&app)?;
|
||||
projects.projects.insert(project_id.to_string(), ProjectSourceConfig::Directory(config.clone()));
|
||||
save_projects_config(&app, &projects)?;
|
||||
|
||||
Ok(ProjectSourceConfig::Directory(config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_project_sources(app: AppHandle, project_id: String) -> Result<LoadedProjectSources, String> {
|
||||
let trimmed_id = project_id.trim().to_string();
|
||||
let projects = load_projects_config(&app)?;
|
||||
let config = projects.projects.get(&trimmed_id).ok_or_else(|| "未找到项目配置".to_string())?;
|
||||
match config {
|
||||
ProjectSourceConfig::Directory(cfg) => {
|
||||
let languages = cfg
|
||||
.files
|
||||
.iter()
|
||||
.map(|(language, path)| {
|
||||
let (exists, content, modified_ms) = read_language_file(Path::new(path));
|
||||
LoadedLanguageFile {
|
||||
language: language.clone(),
|
||||
path: path.clone(),
|
||||
exists,
|
||||
modified_ms,
|
||||
content,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(LoadedProjectSources {
|
||||
project_id: trimmed_id,
|
||||
mode: SourceMode::Directory,
|
||||
base_dir: Some(cfg.base_dir.clone()),
|
||||
default_language: Some(cfg.default_language.clone()),
|
||||
relative_path: Some(cfg.relative_path.clone()),
|
||||
languages,
|
||||
})
|
||||
}
|
||||
ProjectSourceConfig::Flat(_) => Err("单文件模式尚未实现".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_language_file(app: AppHandle, payload: SaveLanguageFilePayload) -> Result<(), String> {
|
||||
let mut projects = load_projects_config(&app)?;
|
||||
let config = projects
|
||||
.projects
|
||||
.get_mut(payload.project_id.trim())
|
||||
.ok_or_else(|| "未找到项目配置".to_string())?;
|
||||
|
||||
match config {
|
||||
ProjectSourceConfig::Directory(cfg) => {
|
||||
let language = payload.language.trim();
|
||||
if language.is_empty() {
|
||||
return Err("语言代码不能为空".into());
|
||||
}
|
||||
let path = determine_language_path(cfg, language)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||
}
|
||||
fs::write(&path, payload.content.as_bytes()).map_err(|e| format!("写入文件失败: {e}"))?;
|
||||
let canonical = canonical_string(&path)?;
|
||||
cfg.files.insert(language.to_string(), canonical);
|
||||
cfg.updated_at = now_ms();
|
||||
save_projects_config(&app, &projects)
|
||||
}
|
||||
ProjectSourceConfig::Flat(_) => Err("单文件模式尚未实现".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_language_folders(base_dir: &Path) -> Result<Vec<LanguageFolderSummary>, String> {
|
||||
let mut folders = Vec::new();
|
||||
for entry in list_language_dirs(base_dir)? {
|
||||
let language = entry.file_name().to_string_lossy().trim().to_string();
|
||||
if language.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let files = collect_json_files(entry.path())?;
|
||||
if files.is_empty() {
|
||||
continue;
|
||||
}
|
||||
folders.push(LanguageFolderSummary {
|
||||
language,
|
||||
file_count: files.len(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
folders.sort_by(|a, b| a.language.cmp(&b.language));
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
fn collect_json_files(dir: PathBuf) -> Result<Vec<LanguageFileSummary>, String> {
|
||||
let mut files = Vec::new();
|
||||
for entry in WalkDir::new(&dir).into_iter().filter_map(|e| e.ok()) {
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !has_json_extension(path) {
|
||||
continue;
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(&dir)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.trim_start_matches(std::path::MAIN_SEPARATOR)
|
||||
.to_string();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| relative.clone());
|
||||
files.push(LanguageFileSummary {
|
||||
file_name,
|
||||
relative_path: if relative.is_empty() {
|
||||
path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()
|
||||
} else {
|
||||
relative
|
||||
},
|
||||
absolute_path: path.to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn list_language_dirs(base_dir: &Path) -> Result<Vec<fs::DirEntry>, String> {
|
||||
let mut dirs = Vec::new();
|
||||
for entry in fs::read_dir(base_dir).map_err(|e| format!("读取目录失败: {e}"))? {
|
||||
let entry = entry.map_err(|e| format!("读取目录失败: {e}"))?;
|
||||
if entry.file_type().map_err(|e| format!("读取目录失败: {e}"))?.is_dir() {
|
||||
dirs.push(entry);
|
||||
}
|
||||
}
|
||||
dirs.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
fn has_json_extension(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("json"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn read_language_file(path: &Path) -> (bool, Option<String>, Option<i64>) {
|
||||
if !path.is_file() {
|
||||
return (false, None, None);
|
||||
}
|
||||
let metadata = match fs::metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => return (false, None, None),
|
||||
};
|
||||
let modified_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|dur| dur.as_millis().min(i64::MAX as u128) as i64);
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(_) => None,
|
||||
};
|
||||
(true, content, modified_ms)
|
||||
}
|
||||
|
||||
fn determine_language_path(cfg: &DirectorySourceConfig, language: &str) -> Result<PathBuf, String> {
|
||||
if let Some(existing) = cfg.files.get(language) {
|
||||
return Ok(PathBuf::from(existing));
|
||||
}
|
||||
let path = PathBuf::from(&cfg.base_dir).join(language).join(&cfg.relative_path);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|dur| dur.as_millis().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn canonical_string(path: &Path) -> Result<String, String> {
|
||||
let resolved = match fs::canonicalize(path) {
|
||||
Ok(p) => p,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => path.to_path_buf(),
|
||||
Err(err) => return Err(format!("解析路径失败: {err}")),
|
||||
};
|
||||
Ok(resolved.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn config_file_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let mut dir = app.path().app_config_dir().map_err(|e| format!("获取配置目录失败: {e}"))?;
|
||||
|
||||
dir.push("sources");
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败: {e}"))?;
|
||||
dir.push("projects.json");
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn load_projects_config(app: &AppHandle) -> Result<ProjectsConfig, String> {
|
||||
let path = config_file_path(app)?;
|
||||
if !path.is_file() {
|
||||
return Ok(ProjectsConfig::default());
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
|
||||
let parsed: ProjectsConfig = serde_json::from_str(&text).map_err(|e| format!("解析配置失败: {e}"))?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn save_projects_config(app: &AppHandle, config: &ProjectsConfig) -> Result<(), String> {
|
||||
let path = config_file_path(app)?;
|
||||
let text = serde_json::to_string_pretty(config).map_err(|e| format!("序列化配置失败: {e}"))?;
|
||||
fs::write(path, text).map_err(|e| format!("写入配置失败: {e}"))
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "translate-it",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.tauri.dev",
|
||||
"identifier": "com.dreamer-paul.translate-it",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5007",
|
||||
@@ -13,8 +13,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Translate It",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user