feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -3,7 +3,7 @@
|
|||||||
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
||||||
|
|
||||||
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
||||||
use notesagent_host::workspace::{Document, Entry, Workspace};
|
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -18,7 +18,7 @@ struct Host {
|
|||||||
fn info(ws: &Workspace) -> RecentVault {
|
fn info(ws: &Workspace) -> RecentVault {
|
||||||
RecentVault {
|
RecentVault {
|
||||||
vault_id: ws.vault_id.clone(),
|
vault_id: ws.vault_id.clone(),
|
||||||
path: ws.root.to_string_lossy().into(),
|
path: portable_path_string(&ws.root),
|
||||||
name: ws
|
name: ws
|
||||||
.root
|
.root
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -164,10 +164,11 @@ fn workspace_open(host: State<'_, Host>, path: String) -> Result<RecentVault, St
|
|||||||
.authorized(Path::new(&path))?
|
.authorized(Path::new(&path))?
|
||||||
.ok_or("VAULT_NOT_AUTHORIZED")?;
|
.ok_or("VAULT_NOT_AUTHORIZED")?;
|
||||||
let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?;
|
let mut guard = host.workspace.lock().map_err(|_| "HOST_BUSY")?;
|
||||||
if guard
|
if guard.as_ref().is_some_and(|ws| {
|
||||||
.as_ref()
|
Path::new(&authorized.path)
|
||||||
.is_some_and(|ws| ws.root == Path::new(&authorized.path))
|
.canonicalize()
|
||||||
{
|
.is_ok_and(|path| ws.root == path)
|
||||||
|
}) {
|
||||||
return Ok(guard.as_ref().map(info).ok_or("VAULT_NOT_OPEN")?);
|
return Ok(guard.as_ref().map(info).ok_or("VAULT_NOT_OPEN")?);
|
||||||
}
|
}
|
||||||
let workspace = Workspace::open(Path::new(&authorized.path)).map_err(|e| e.code)?;
|
let workspace = Workspace::open(Path::new(&authorized.path)).map_err(|e| e.code)?;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::workspace::{portable_path, portable_path_string};
|
||||||
|
|
||||||
const MAX_RECENT_VAULTS: i64 = 20;
|
const MAX_RECENT_VAULTS: i64 = 20;
|
||||||
|
|
||||||
@@ -52,10 +54,20 @@ impl RecentVaultStore {
|
|||||||
})
|
})
|
||||||
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
||||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map(|vaults| {
|
||||||
|
vaults
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut vault| {
|
||||||
|
vault.path = portable_path_string(Path::new(&vault.path));
|
||||||
|
vault
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.map_err(|_| "RECENT_VAULT_STORE_ERROR".into())
|
.map_err(|_| "RECENT_VAULT_STORE_ERROR".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remember(&mut self, vault: &RecentVault) -> Result<(), String> {
|
pub fn remember(&mut self, vault: &RecentVault) -> Result<(), String> {
|
||||||
|
let (canonical, portable) = path_forms(Path::new(&vault.path))?;
|
||||||
let transaction = self
|
let transaction = self
|
||||||
.db
|
.db
|
||||||
.transaction()
|
.transaction()
|
||||||
@@ -67,11 +79,17 @@ impl RecentVaultStore {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM recent_vaults WHERE path=? OR path=?",
|
||||||
|
params![canonical, portable],
|
||||||
|
)
|
||||||
|
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
||||||
transaction
|
transaction
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO recent_vaults(path,vault_id,name,ordering) VALUES(?,?,?,?)
|
"INSERT INTO recent_vaults(path,vault_id,name,ordering) VALUES(?,?,?,?)
|
||||||
ON CONFLICT(path) DO UPDATE SET vault_id=excluded.vault_id,name=excluded.name,ordering=excluded.ordering",
|
ON CONFLICT(path) DO UPDATE SET vault_id=excluded.vault_id,name=excluded.name,ordering=excluded.ordering",
|
||||||
params![vault.path, vault.vault_id, vault.name, ordering],
|
params![portable, vault.vault_id, vault.name, ordering],
|
||||||
)
|
)
|
||||||
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
||||||
transaction
|
transaction
|
||||||
@@ -88,11 +106,11 @@ impl RecentVaultStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn authorized(&self, path: &Path) -> Result<Option<RecentVault>, String> {
|
pub fn authorized(&self, path: &Path) -> Result<Option<RecentVault>, String> {
|
||||||
let canonical = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?;
|
let (canonical, portable) = path_forms(path)?;
|
||||||
self.db
|
let result = self.db
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT vault_id,path,name FROM recent_vaults WHERE path=?",
|
"SELECT vault_id,path,name FROM recent_vaults WHERE path=? OR path=? ORDER BY ordering DESC LIMIT 1",
|
||||||
[canonical.to_string_lossy().as_ref()],
|
params![canonical, portable],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(RecentVault {
|
Ok(RecentVault {
|
||||||
vault_id: row.get(0)?,
|
vault_id: row.get(0)?,
|
||||||
@@ -102,21 +120,36 @@ impl RecentVaultStore {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|_| "RECENT_VAULT_STORE_ERROR".into())
|
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
||||||
|
Ok(result.map(|mut vault| {
|
||||||
|
vault.path = portable_path_string(Path::new(&vault.path));
|
||||||
|
vault
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn revoke(&mut self, path: &Path) -> Result<(), String> {
|
pub fn revoke(&mut self, path: &Path) -> Result<(), String> {
|
||||||
let canonical: PathBuf = path.canonicalize().map_err(|_| "VAULT_PATH_UNSUPPORTED")?;
|
let (canonical, portable) = path_forms(path)?;
|
||||||
self.db
|
self.db
|
||||||
.execute(
|
.execute(
|
||||||
"DELETE FROM recent_vaults WHERE path=?",
|
"DELETE FROM recent_vaults WHERE path=? OR path=?",
|
||||||
[canonical.to_string_lossy().as_ref()],
|
params![canonical, portable],
|
||||||
)
|
)
|
||||||
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
.map_err(|_| "RECENT_VAULT_STORE_ERROR")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn path_forms(path: &Path) -> Result<(String, String), String> {
|
||||||
|
let accessible = portable_path(path);
|
||||||
|
let canonical = accessible
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|_| "VAULT_PATH_UNSUPPORTED")?;
|
||||||
|
Ok((
|
||||||
|
canonical.to_string_lossy().into_owned(),
|
||||||
|
portable_path_string(&canonical),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -126,7 +159,7 @@ mod tests {
|
|||||||
fs::create_dir(&path).unwrap();
|
fs::create_dir(&path).unwrap();
|
||||||
RecentVault {
|
RecentVault {
|
||||||
vault_id: format!("id-{id}"),
|
vault_id: format!("id-{id}"),
|
||||||
path: path.canonicalize().unwrap().to_string_lossy().into(),
|
path: portable_path_string(&path.canonicalize().unwrap()),
|
||||||
name: format!("Vault {id}"),
|
name: format!("Vault {id}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,4 +198,39 @@ mod tests {
|
|||||||
assert_eq!(items[0].vault_id, "id-24");
|
assert_eq!(items[0].vault_id, "id-24");
|
||||||
assert_eq!(items[19].vault_id, "id-5");
|
assert_eq!(items[19].vault_id, "id-5");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn reads_and_replaces_legacy_verbatim_paths() {
|
||||||
|
let temporary = tempfile::tempdir().unwrap();
|
||||||
|
let database = temporary.path().join("host.sqlite3");
|
||||||
|
let current = vault(temporary.path(), 1);
|
||||||
|
let legacy = Path::new(¤t.path)
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
assert!(legacy.starts_with(r"\\?\"));
|
||||||
|
|
||||||
|
let mut store = RecentVaultStore::open(&database).unwrap();
|
||||||
|
store
|
||||||
|
.db
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO recent_vaults VALUES (?,?,?,1)",
|
||||||
|
params![legacy, current.vault_id, current.name],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(store.list().unwrap(), vec![current.clone()]);
|
||||||
|
assert_eq!(
|
||||||
|
store.authorized(Path::new(¤t.path)).unwrap(),
|
||||||
|
Some(current.clone())
|
||||||
|
);
|
||||||
|
store.remember(¤t).unwrap();
|
||||||
|
let stored: String = store
|
||||||
|
.db
|
||||||
|
.query_row("SELECT path FROM recent_vaults", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored, current.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,30 @@ fn linked(path: &Path) -> std::io::Result<bool> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将 Windows 本地磁盘的 verbatim 路径转换为适合界面和持久化的普通路径。
|
||||||
|
/// UNC 与其他设备路径保持原样,后续仍会被 Vault 安全检查拒绝。
|
||||||
|
pub fn portable_path(path: &Path) -> PathBuf {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let raw = path.to_string_lossy();
|
||||||
|
if let Some(rest) = raw.strip_prefix(r"\\?\") {
|
||||||
|
let bytes = rest.as_bytes();
|
||||||
|
if bytes.len() >= 3
|
||||||
|
&& bytes[0].is_ascii_alphabetic()
|
||||||
|
&& bytes[1] == b':'
|
||||||
|
&& matches!(bytes[2], b'\\' | b'/')
|
||||||
|
{
|
||||||
|
return PathBuf::from(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn portable_path_string(path: &Path) -> String {
|
||||||
|
portable_path(path).to_string_lossy().into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
pub vault_id: String,
|
pub vault_id: String,
|
||||||
@@ -82,7 +106,8 @@ pub struct Workspace {
|
|||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
pub fn open(root: &Path) -> Result<Self> {
|
pub fn open(root: &Path) -> Result<Self> {
|
||||||
if !root.is_dir() || linked(root)? || root.to_string_lossy().starts_with("\\\\") {
|
let root = portable_path(root);
|
||||||
|
if root.to_string_lossy().starts_with("\\\\") || !root.is_dir() || linked(&root)? {
|
||||||
return Err(HostError::new("VAULT_PATH_UNSUPPORTED"));
|
return Err(HostError::new("VAULT_PATH_UNSUPPORTED"));
|
||||||
}
|
}
|
||||||
let root = root.canonicalize()?;
|
let root = root.canonicalize()?;
|
||||||
@@ -517,6 +542,17 @@ impl Workspace {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn opens_windows_verbatim_disk_path() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let verbatim = dir.path().canonicalize().unwrap();
|
||||||
|
assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
|
||||||
|
|
||||||
|
let ws = Workspace::open(&verbatim).unwrap();
|
||||||
|
assert_eq!(portable_path(&ws.root), portable_path(&verbatim));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rename_and_delete_recover_after_source_removed() {
|
fn rename_and_delete_recover_after_source_removed() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user