From 0774b70d29467d42e1e88bdd2f74eccc0dcb6e34 Mon Sep 17 00:00:00 2001 From: Muse Date: Fri, 13 Nov 2026 22:43:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(stage4):=20Rust=20SQLite=20=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=B1=82=E4=B8=8E=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 120 ++++++++++++++++++++++++ src-tauri/Cargo.toml | 2 + src-tauri/src/commands.rs | 154 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 32 +++++++ src-tauri/src/storage.rs | 192 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 500 insertions(+) create mode 100644 src-tauri/src/storage.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ae3ccbf..4fab50b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -757,6 +769,18 @@ dependencies = [ "typeid", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1204,12 +1228,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1739,6 +1781,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.3" @@ -1896,11 +1949,13 @@ name = "new-markdown" version = "0.1.0" dependencies = [ "encoding_rs", + "rusqlite", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", + "trash", ] [[package]] @@ -2615,6 +2670,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3774,6 +3843,25 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trash" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e13ed7a76ae8b9053f33493745ebb4cd2e5ee1cccd9823bb96ec97b78ec4844" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", + "windows-core", +] + [[package]] name = "tray-icon" version = "0.25.1" @@ -3839,6 +3927,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.6.0" @@ -3878,6 +3972,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4502,6 +4602,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b565778..ff9fabe 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,3 +17,5 @@ tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" encoding_rs = "0.8" +rusqlite = { version = "0.32", features = ["bundled"] } +trash = "5" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index de4f89c..8785558 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -217,3 +217,157 @@ pub fn list_dir(path: String) -> Result, String> { }); Ok(entries) } + +// ————————————————— 阶段 4:文件状态与管理工作区命令 ————————————————— + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileStat { + pub exists: bool, + /// 修改时间(毫秒,自 Unix 纪元);文件不存在时为 0 + pub mtime_ms: i64, + pub size: u64, +} + +/// 文件状态探针:外部修改检测的基线与轮询都用它(只读元数据,不读内容)。 +#[tauri::command] +pub fn stat_file(path: String) -> FileStat { + match fs::metadata(&path) { + Ok(meta) => { + let mtime_ms = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + FileStat { exists: true, mtime_ms, size: meta.len() } + } + Err(_) => FileStat { exists: false, mtime_ms: 0, size: 0 }, + } +} + +/// 新建空文本文件;目标已存在时报错(不覆盖)。 +#[tauri::command] +pub fn create_text_file(path: String) -> Result<(), String> { + let p = Path::new(&path); + if p.exists() { + return Err("同名文件已存在".to_string()); + } + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建目录失败:{e}"))?; + } + fs::write(p, "").map_err(|e| format!("创建文件失败:{e}")) +} + +#[tauri::command] +pub fn create_dir(path: String) -> Result<(), String> { + fs::create_dir_all(&path).map_err(|e| format!("创建文件夹失败:{e}")) +} + +/// 重命名/移动文件或文件夹;目标已存在时报错。 +#[tauri::command] +pub fn rename_path(from: String, to: String) -> Result<(), String> { + if Path::new(&to).exists() { + return Err("目标名称已存在".to_string()); + } + fs::rename(&from, &to).map_err(|e| format!("重命名失败:{e}")) +} + +/// 删除到系统回收站(失败时退回永久删除仅限文件)。 +#[tauri::command] +pub fn delete_to_trash(path: String) -> Result<(), String> { + match trash::delete(&path) { + Ok(()) => Ok(()), + Err(e) => { + let p = Path::new(&path); + if p.is_file() { + fs::remove_file(p).map_err(|e2| format!("删除失败:{e};备用删除也失败:{e2}")) + } else { + Err(format!("删除失败:{e}")) + } + } + } +} + +// ————————————————— 阶段 4:SQLite 存储命令(逻辑在 storage.rs) ————————————————— + +use crate::storage; +use crate::AppDb; +use tauri::State; + +fn lock_db<'a>(db: &'a State<'a, AppDb>) -> Result, String> { + db.0.lock().map_err(|_| "数据库锁损坏".to_string()) +} + +#[tauri::command] +pub fn db_recent_list(db: State<'_, AppDb>) -> Result, String> { + let conn = lock_db(&db)?; + storage::recent_list(&conn, 30) +} + +#[tauri::command] +pub fn db_recent_add(db: State<'_, AppDb>, path: String, name: String, opened_at: i64) -> Result<(), String> { + let conn = lock_db(&db)?; + storage::recent_add(&conn, &path, &name, opened_at, 30) +} + +#[tauri::command] +pub fn db_recent_remove(db: State<'_, AppDb>, path: String) -> Result<(), String> { + let conn = lock_db(&db)?; + storage::recent_remove(&conn, &path) +} + +#[tauri::command] +pub fn db_recent_clear(db: State<'_, AppDb>) -> Result<(), String> { + let conn = lock_db(&db)?; + storage::recent_clear(&conn) +} + +#[tauri::command] +pub fn db_workspace_list(db: State<'_, AppDb>) -> Result, String> { + let conn = lock_db(&db)?; + storage::workspace_list(&conn) +} + +#[tauri::command] +pub fn db_workspace_add(db: State<'_, AppDb>, path: String, opened_at: i64) -> Result<(), String> { + let conn = lock_db(&db)?; + storage::workspace_add(&conn, &path, opened_at) +} + +#[tauri::command] +pub fn db_pref_get(db: State<'_, AppDb>, key: String) -> Result, String> { + let conn = lock_db(&db)?; + storage::pref_get(&conn, &key) +} + +#[tauri::command] +pub fn db_pref_set(db: State<'_, AppDb>, key: String, value: String) -> Result<(), String> { + let conn = lock_db(&db)?; + storage::pref_set(&conn, &key, &value) +} + +#[tauri::command] +pub fn db_recovery_list(db: State<'_, AppDb>) -> Result, String> { + let conn = lock_db(&db)?; + storage::recovery_list(&conn) +} + +#[tauri::command] +pub fn db_recovery_save( + db: State<'_, AppDb>, + doc_key: String, + path: Option, + title: String, + content: String, + updated_at: i64, +) -> Result<(), String> { + let conn = lock_db(&db)?; + storage::recovery_save(&conn, &doc_key, path.as_deref(), &title, &content, updated_at) +} + +#[tauri::command] +pub fn db_recovery_delete(db: State<'_, AppDb>, doc_key: String) -> Result<(), String> { + let conn = lock_db(&db)?; + storage::recovery_delete(&conn, &doc_key) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5ad03e4..5a3dd28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,15 +1,47 @@ mod commands; +mod storage; + +use std::sync::Mutex; +use tauri::Manager; + +/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。 +pub struct AppDb(pub Mutex); #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + let data_dir = app.path().app_data_dir().expect("app data dir"); + std::fs::create_dir_all(&data_dir).expect("create app data dir"); + let conn = rusqlite::Connection::open(data_dir.join("new-markdown.db")) + .expect("open app database"); + storage::init_schema(&conn).expect("init app database schema"); + app.manage(AppDb(Mutex::new(conn))); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ commands::read_text_file, commands::write_text_file, commands::list_dir, commands::copy_to_assets, commands::save_bytes_to_assets, + commands::stat_file, + commands::create_text_file, + commands::create_dir, + commands::rename_path, + commands::delete_to_trash, + commands::db_recent_list, + commands::db_recent_add, + commands::db_recent_remove, + commands::db_recent_clear, + commands::db_workspace_list, + commands::db_workspace_add, + commands::db_pref_get, + commands::db_pref_set, + commands::db_recovery_list, + commands::db_recovery_save, + commands::db_recovery_delete, ]) .run(tauri::generate_context!()) .expect("error while running new-markdown"); diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..f824291 --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,192 @@ +//! SQLite 存储层(阶段 4):最近文件、工作区、偏好、崩溃恢复记录。 +//! 红线(docs/02):这里永远不存文档正文的唯一副本——正文只在 .md 文件里; +//! recovery 表是未保存草稿的副本,仅用于崩溃/误关后恢复,保存或确认丢弃即删。 +//! 本文件不依赖 Tauri 类型,命令层(commands.rs)只做参数转发,便于独立校验。 + +use rusqlite::{params, Connection}; +use serde::Serialize; + +pub fn init_schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS recent_files ( + path TEXT PRIMARY KEY, + name TEXT NOT NULL, + opened_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS workspaces ( + path TEXT PRIMARY KEY, + opened_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS prefs ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS recovery ( + doc_key TEXT PRIMARY KEY, + path TEXT, + title TEXT NOT NULL, + content TEXT NOT NULL, + updated_at INTEGER NOT NULL + );", + ) + .map_err(|e| format!("初始化数据库失败:{e}")) +} + +fn err(e: E) -> String { + format!("数据库操作失败:{e}") +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecentEntry { + pub path: String, + pub name: String, + pub opened_at: i64, +} + +pub fn recent_list(conn: &Connection, limit: i64) -> Result, String> { + let mut stmt = conn + .prepare("SELECT path, name, opened_at FROM recent_files ORDER BY opened_at DESC LIMIT ?1") + .map_err(err)?; + let rows = stmt + .query_map(params![limit], |row| { + Ok(RecentEntry { + path: row.get(0)?, + name: row.get(1)?, + opened_at: row.get(2)?, + }) + }) + .map_err(err)?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(err)?); + } + Ok(out) +} + +pub fn recent_add(conn: &Connection, path: &str, name: &str, opened_at: i64, keep: i64) -> Result<(), String> { + conn.execute( + "INSERT INTO recent_files (path, name, opened_at) VALUES (?1, ?2, ?3) + ON CONFLICT(path) DO UPDATE SET name = excluded.name, opened_at = excluded.opened_at", + params![path, name, opened_at], + ) + .map_err(err)?; + conn.execute( + "DELETE FROM recent_files WHERE path NOT IN (SELECT path FROM recent_files ORDER BY opened_at DESC LIMIT ?1)", + params![keep], + ) + .map_err(err)?; + Ok(()) +} + +pub fn recent_remove(conn: &Connection, path: &str) -> Result<(), String> { + conn.execute("DELETE FROM recent_files WHERE path = ?1", params![path]) + .map_err(err)?; + Ok(()) +} + +pub fn recent_clear(conn: &Connection) -> Result<(), String> { + conn.execute("DELETE FROM recent_files", []).map_err(err)?; + Ok(()) +} + +pub fn workspace_list(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare("SELECT path FROM workspaces ORDER BY opened_at DESC") + .map_err(err)?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(err)?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(err)?); + } + Ok(out) +} + +pub fn workspace_add(conn: &Connection, path: &str, opened_at: i64) -> Result<(), String> { + conn.execute( + "INSERT INTO workspaces (path, opened_at) VALUES (?1, ?2) + ON CONFLICT(path) DO UPDATE SET opened_at = excluded.opened_at", + params![path, opened_at], + ) + .map_err(err)?; + Ok(()) +} + +pub fn pref_get(conn: &Connection, key: &str) -> Result, String> { + let mut stmt = conn + .prepare("SELECT value FROM prefs WHERE key = ?1") + .map_err(err)?; + let mut rows = stmt.query(params![key]).map_err(err)?; + match rows.next().map_err(err)? { + Some(row) => Ok(Some(row.get(0).map_err(err)?)), + None => Ok(None), + } +} + +pub fn pref_set(conn: &Connection, key: &str, value: &str) -> Result<(), String> { + conn.execute( + "INSERT INTO prefs (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + ) + .map_err(err)?; + Ok(()) +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecoveryEntry { + pub doc_key: String, + pub path: Option, + pub title: String, + pub content: String, + pub updated_at: i64, +} + +pub fn recovery_list(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare("SELECT doc_key, path, title, content, updated_at FROM recovery ORDER BY updated_at DESC") + .map_err(err)?; + let rows = stmt + .query_map([], |row| { + Ok(RecoveryEntry { + doc_key: row.get(0)?, + path: row.get(1)?, + title: row.get(2)?, + content: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .map_err(err)?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(err)?); + } + Ok(out) +} + +pub fn recovery_save( + conn: &Connection, + doc_key: &str, + path: Option<&str>, + title: &str, + content: &str, + updated_at: i64, +) -> Result<(), String> { + conn.execute( + "INSERT INTO recovery (doc_key, path, title, content, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(doc_key) DO UPDATE SET path = excluded.path, title = excluded.title, + content = excluded.content, updated_at = excluded.updated_at", + params![doc_key, path, title, content, updated_at], + ) + .map_err(err)?; + Ok(()) +} + +pub fn recovery_delete(conn: &Connection, doc_key: &str) -> Result<(), String> { + conn.execute("DELETE FROM recovery WHERE doc_key = ?1", params![doc_key]) + .map_err(err)?; + Ok(()) +}