feat(desktop): 增加原生 Vault 写入与属性导入

This commit is contained in:
2026-09-07 16:52:30 +08:00
parent 7fffbcd55a
commit afb76dc325
40 changed files with 6870 additions and 26 deletions
+43
View File
@@ -1,10 +1,52 @@
import asyncio
from contextlib import contextmanager
from functools import wraps
from weakref import WeakKeyDictionary
_vault_locks = WeakKeyDictionary()
@contextmanager
def web_vault_ownership():
"""与 Rust fs2 使用同一 OS 文件锁,避免首次切换时两套写入者重叠。"""
from app.config import get_settings
from app.errors import ApiError
root = get_settings().vault_path
managed = root / '.ainote'
if managed.is_symlink() or (hasattr(managed, 'is_junction') and managed.is_junction()):
raise ApiError(403, 'WORKSPACE_UNSAFE_PATH', '工作区元数据路径不安全')
managed.mkdir(parents=True, exist_ok=True)
path = managed / 'host.lock'
if path.is_symlink():
raise ApiError(403, 'WORKSPACE_UNSAFE_PATH', '工作区锁路径不安全')
with path.open('a+b') as stream:
import os
locked = False
try:
stream.seek(0)
try:
if os.name == 'nt':
import msvcrt
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = True
except OSError:
raise ApiError(409, 'WORKSPACE_OWNER_BUSY', '工作区由其他进程持有,请稍后重试') from None
# 桌面元数据已建立后必须经 Host 写入;不以进程退出自动降回 Web 所有权。
if (managed / 'host.sqlite3').exists():
raise ApiError(409, 'WORKSPACE_OWNER_DESKTOP', '该 Vault 已由桌面 Host 管理,Web 禁止写入')
yield
finally:
if locked:
stream.seek(0)
if os.name == 'nt':
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
def vault_mutation_lock():
# Service/test lifecycle restarts must not reuse a lock bound to a closed loop.
loop = asyncio.get_running_loop()
@@ -17,6 +59,7 @@ def serialized_vault_mutation(operation):
@wraps(operation)
async def wrapped(*args, **kwargs):
async with vault_mutation_lock():
with web_vault_ownership():
return await operation(*args, **kwargs)
return wrapped
+25
View File
@@ -0,0 +1,25 @@
"""单写入者门禁使用受控目录,拒绝 Web 绕过已迁移的桌面 Vault。"""
import pytest
from app.config import get_settings
from app.errors import ApiError
from app.services.coordination import web_vault_ownership
def test_web_refuses_desktop_owned_vault():
root = get_settings().vault_path
(root / '.ainote').mkdir(parents=True)
(root / '.ainote' / 'host.sqlite3').write_bytes(b'fixture-marker')
with pytest.raises(ApiError) as error:
with web_vault_ownership():
pytest.fail('不应取得桌面写入权')
assert error.value.code == 'WORKSPACE_OWNER_DESKTOP'
def test_web_lock_is_exclusive_and_released():
with web_vault_ownership():
with pytest.raises(ApiError):
with web_vault_ownership():
pytest.fail('不应同时持有锁')
with web_vault_ownership():
pass
+38
View File
@@ -0,0 +1,38 @@
# Rust Host v1 预览契约
状态:Rust 核心测试、clippy、Tauri 工程编译及 Windows GNU 开发可执行文件构建通过。**尚无可发布安装包,未完成原生界面实测**。不将开发构建等同 D01–D08 全部通过。
## 命令
只允许本地 `main` WebView 使用 capability。不存在通用 shell、任意路径打开、任意 HTTP 转发或明文凭据读取命令。目录授权由原生目录选择器完成。
| 命令 | 参数 / 返回 |
| --- | --- |
| `host_capabilities` | protocol=1workspace=truecore/sync/credentials/extensions=false |
| `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name |
| `workspace_recent` | 当前进程已打开的 Vault;尚未持久化最近列表 |
| `workspace_revoke` | 释放当前 Vault;不删除用户内容 |
| `workspace_tree` | Markdown 与目录树;file_id/path/hash/revision/deleted/is_folder |
| `workspace_read` | path,返回条目与 UTF-8 content |
| `workspace_write` | path、expectedSHA-256)、content;返回新条目 |
| `workspace_rename` | path、destination、expected;文件 ID 保持不变 |
| `workspace_delete` | path、expected;正文保存在 `.ainote/trash` |
| `workspace_mkdir` | path,相对当前授权 Vault |
当前错误通过 Tauri rejection 返回稳定 code,前端 `DesktopError` 保留该 code。主要 code 为 REVISION_CONFLICT、RECOVERY_CONFLICT、UNSAFE_PATH、PATH_CONFLICT、VAULT_ALREADY_OPEN、SCHEMA_INCOMPATIBLE、ATOMIC_REPLACE_FAILED。统一 request_id、取消标识及完整错误详情尚未固化。
## 写入及恢复
OS 文件锁配合 Rust Mutex 维持单实例 Vault 写入。Web `serialized_vault_mutation` 使用同一 OS 文件锁;发现 `.ainote/host.sqlite3` 后拒绝 Web 写入,不自动降级。已有个人 Vault 不会被测试读取或迁移。
保存前对比实际磁盘 SHA-256,先持久化包含内容的 journal,再同目录临时文件刷盘和替换,最后提交元数据及 outbox。文件已替换但数据库未提交时,启动恢复补齐同一 operation_id。摘要不匹配则保留 journal 冲突及外部正文。remote origin 不再次入 outbox。outbox 目前只持久化,**尚无上传/拉取循环**。
移动和删除另有 file_ops 日志;目标或回收正文刷盘后才删除来源,数据库失败可重放。Windows reparse point(包括 junction)、越界路径、保留目录和设备名被拒绝。大小写重命名暂报 PATH_CONFLICT,UNC 不支持;附件树、多窗口共享、目录移动/删除、稳定外部重命名识别、监听去重及完整恢复 UI 尚未实现。
本地恶意进程仍可能在路径校验与系统调用间改变文件系统;该预览不是 OS 沙箱。发布前须补句柄级路径固定及突破测试、磁盘满/掉电故障注入、真实 WebView 交互、性能门禁。
## 前端与发布
Web 模式沿用现有服务。Tauri Workspace Service 调用原生命令;AI Core 未接通时明确返回 CORE_UNAVAILABLE。关闭请求先尝试保存;冲突或保存失败时保持窗口。原生“导入为笔记属性”菜单已创建但禁用,转换事务尚未完成。
`bundle.active=false`,无自动更新、无签名安装包。开发命令为前端 `pnpm dev``cargo run --features desktop`;前端生产构建后可 `cargo build --features desktop --release`,仍只构建预览程序。需要对应平台 C++ 工具链和 WebView;本次 Windows 使用工作树内隔离 GNU/LLVM 工具链,不替代官方 MSVC 或其他平台构建。
+2 -1
View File
@@ -8,7 +8,7 @@
"build": "vue-tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json && vue-tsc --noEmit -p tsconfig.node.json",
"build:report": "node scripts/build-size.mjs"
},
"dependencies": {
@@ -33,6 +33,7 @@
"@shikijs/engine-javascript": "4.4.3",
"@shikijs/langs": "4.4.3",
"@shikijs/themes": "4.4.3",
"@tauri-apps/api": "^2.11.1",
"@vueuse/core": "^14.0.0",
"codemirror": "^6.0.0",
"dompurify": "^3.4.14",
+8
View File
@@ -71,6 +71,9 @@ importers:
'@shikijs/themes':
specifier: 4.4.3
version: 4.4.3
'@tauri-apps/api':
specifier: ^2.11.1
version: 2.11.1
'@vueuse/core':
specifier: ^14.0.0
version: 14.4.0(vue@3.5.42(typescript@5.9.3))
@@ -838,6 +841,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tauri-apps/api@2.11.1':
resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -3314,6 +3320,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@tauri-apps/api@2.11.1': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
+5292
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "notesagent-desktop"
version = "0.3.0-alpha.1"
edition = "2021"
rust-version = "1.85"
[lib]
name = "notesagent_host"
[[bin]]
name = "notesagent-desktop"
path = "src/main.rs"
required-features = ["desktop"]
[features]
default = []
desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
uuid = { version = "1", features = ["v4"] }
rusqlite = { version = "0.32", features = ["bundled"] }
tempfile = "3"
fs2 = "0.4"
tauri = { version = "2", optional = true, features = ["tray-icon"] }
rfd = { version = "0.15", optional = true }
[build-dependencies]
tauri-build = { version = "2", optional = true }
+19
View File
@@ -0,0 +1,19 @@
fn main() {
#[cfg(feature = "desktop")]
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
tauri_build::AppManifest::new().commands(&[
"host_capabilities",
"editor_capabilities",
"workspace_choose",
"workspace_tree",
"workspace_read",
"workspace_write",
"workspace_rename",
"workspace_delete",
"workspace_mkdir",
"workspace_recent",
"workspace_revoke",
]),
))
.expect("Tauri 配置无效");
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "仅本地应用主窗口可请求受限 Vault 命令;远程来源没有授权。",
"windows": [
"main"
],
"permissions": [
"core:default",
"allow-host-capabilities",
"allow-workspace-choose",
"allow-workspace-tree",
"allow-workspace-read",
"allow-workspace-write",
"allow-workspace-rename",
"allow-workspace-delete",
"allow-workspace-mkdir",
"allow-workspace-recent",
"allow-workspace-revoke",
"core:window:allow-destroy",
"allow-editor-capabilities"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-editor-capabilities"
description = "Enables the editor_capabilities command without any pre-configured scope."
commands.allow = ["editor_capabilities"]
[[permission]]
identifier = "deny-editor-capabilities"
description = "Denies the editor_capabilities command without any pre-configured scope."
commands.deny = ["editor_capabilities"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-host-capabilities"
description = "Enables the host_capabilities command without any pre-configured scope."
commands.allow = ["host_capabilities"]
[[permission]]
identifier = "deny-host-capabilities"
description = "Denies the host_capabilities command without any pre-configured scope."
commands.deny = ["host_capabilities"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-choose"
description = "Enables the workspace_choose command without any pre-configured scope."
commands.allow = ["workspace_choose"]
[[permission]]
identifier = "deny-workspace-choose"
description = "Denies the workspace_choose command without any pre-configured scope."
commands.deny = ["workspace_choose"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-delete"
description = "Enables the workspace_delete command without any pre-configured scope."
commands.allow = ["workspace_delete"]
[[permission]]
identifier = "deny-workspace-delete"
description = "Denies the workspace_delete command without any pre-configured scope."
commands.deny = ["workspace_delete"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-mkdir"
description = "Enables the workspace_mkdir command without any pre-configured scope."
commands.allow = ["workspace_mkdir"]
[[permission]]
identifier = "deny-workspace-mkdir"
description = "Denies the workspace_mkdir command without any pre-configured scope."
commands.deny = ["workspace_mkdir"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-read"
description = "Enables the workspace_read command without any pre-configured scope."
commands.allow = ["workspace_read"]
[[permission]]
identifier = "deny-workspace-read"
description = "Denies the workspace_read command without any pre-configured scope."
commands.deny = ["workspace_read"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-recent"
description = "Enables the workspace_recent command without any pre-configured scope."
commands.allow = ["workspace_recent"]
[[permission]]
identifier = "deny-workspace-recent"
description = "Denies the workspace_recent command without any pre-configured scope."
commands.deny = ["workspace_recent"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-rename"
description = "Enables the workspace_rename command without any pre-configured scope."
commands.allow = ["workspace_rename"]
[[permission]]
identifier = "deny-workspace-rename"
description = "Denies the workspace_rename command without any pre-configured scope."
commands.deny = ["workspace_rename"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-revoke"
description = "Enables the workspace_revoke command without any pre-configured scope."
commands.allow = ["workspace_revoke"]
[[permission]]
identifier = "deny-workspace-revoke"
description = "Denies the workspace_revoke command without any pre-configured scope."
commands.deny = ["workspace_revoke"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-tree"
description = "Enables the workspace_tree command without any pre-configured scope."
commands.allow = ["workspace_tree"]
[[permission]]
identifier = "deny-workspace-tree"
description = "Denies the workspace_tree command without any pre-configured scope."
commands.deny = ["workspace_tree"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-workspace-write"
description = "Enables the workspace_write command without any pre-configured scope."
commands.allow = ["workspace_write"]
[[permission]]
identifier = "deny-workspace-write"
description = "Denies the workspace_write command without any pre-configured scope."
commands.deny = ["workspace_write"]
+3
View File
@@ -0,0 +1,3 @@
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
pub mod workspace;
+168
View File
@@ -0,0 +1,168 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
use notesagent_host::workspace::{Document, Entry, Workspace};
use serde::Serialize;
use std::sync::Mutex;
use tauri::{Emitter, Manager, State};
#[derive(Default)]
struct Host(Mutex<Option<Workspace>>);
#[derive(Serialize)]
struct VaultInfo {
vault_id: String,
path: String,
name: String,
}
fn info(ws: &Workspace) -> VaultInfo {
VaultInfo {
vault_id: ws.vault_id.clone(),
path: ws.root.to_string_lossy().into(),
name: ws
.root
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into(),
}
}
fn with_workspace<T>(
host: &Host,
f: impl FnOnce(&mut Workspace) -> notesagent_host::workspace::Result<T>,
) -> Result<T, String> {
let mut guard = host.0.lock().map_err(|_| "HOST_BUSY")?;
let ws = guard.as_mut().ok_or("VAULT_NOT_OPEN")?;
f(ws).map_err(|e| e.code)
}
#[tauri::command]
fn host_capabilities() -> serde_json::Value {
serde_json::json!({"protocol":1,"workspace":true,"core":false,"sync":false,"credentials":false,"extensions":false,"release":"preview"})
}
#[tauri::command]
fn editor_capabilities(app: tauri::AppHandle, import_enabled: bool) -> Result<(), String> {
app.state::<tauri::menu::MenuItem<tauri::Wry>>()
.set_enabled(import_enabled)
.map_err(|_| "MENU_UNAVAILABLE".into())
}
#[tauri::command]
fn workspace_choose(host: State<'_, Host>) -> Result<Option<VaultInfo>, String> {
let Some(path) = rfd::FileDialog::new()
.set_title("选择本地 Vault")
.pick_folder()
else {
return Ok(None);
};
let mut guard = host.0.lock().map_err(|_| "HOST_BUSY")?;
if guard
.as_ref()
.is_some_and(|ws| ws.root == path.canonicalize().unwrap_or_default())
{
return Ok(guard.as_ref().map(info));
}
let workspace = Workspace::open(&path).map_err(|e| e.code)?;
let result = info(&workspace);
*guard = Some(workspace);
Ok(Some(result))
}
#[tauri::command]
fn workspace_recent(host: State<'_, Host>) -> Result<Vec<VaultInfo>, String> {
let guard = host.0.lock().map_err(|_| "HOST_BUSY")?;
Ok(guard.as_ref().map(info).into_iter().collect())
}
#[tauri::command]
fn workspace_revoke(host: State<'_, Host>) -> Result<(), String> {
*host.0.lock().map_err(|_| "HOST_BUSY")? = None;
Ok(())
}
#[tauri::command]
fn workspace_tree(host: State<'_, Host>) -> Result<Vec<Entry>, String> {
with_workspace(&host, |ws| ws.scan())
}
#[tauri::command]
fn workspace_read(host: State<'_, Host>, path: String) -> Result<Document, String> {
with_workspace(&host, |ws| ws.read(&path))
}
#[tauri::command]
fn workspace_write(
host: State<'_, Host>,
path: String,
expected: String,
content: String,
) -> Result<Entry, String> {
with_workspace(&host, |ws| {
ws.write(&path, &expected, content.as_bytes(), "local")
})
}
#[tauri::command]
fn workspace_rename(
host: State<'_, Host>,
path: String,
destination: String,
expected: String,
) -> Result<Entry, String> {
with_workspace(&host, |ws| ws.rename(&path, &destination, &expected))
}
#[tauri::command]
fn workspace_delete(host: State<'_, Host>, path: String, expected: String) -> Result<(), String> {
with_workspace(&host, |ws| ws.delete(&path, &expected))
}
#[tauri::command]
fn workspace_mkdir(host: State<'_, Host>, path: String) -> Result<(), String> {
with_workspace(&host, |ws| ws.mkdir(&path))
}
fn main() {
tauri::Builder::default()
.manage(Host::default())
.setup(|app| {
use tauri::menu::{Menu, MenuItem, Submenu};
let import = MenuItem::with_id(
app,
"editor.import-note-properties",
"导入为笔记属性…",
false,
None::<&str>,
)?;
let paragraph = Submenu::with_items(app, "段落", true, &[&import])?;
app.manage(import);
app.set_menu(Menu::with_items(app, &[&paragraph])?)?;
Ok(())
})
.on_menu_event(|app, event| {
// 主窗口独占预览 Vault;扩展窗口没有命令 capability。
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("editor-command", event.id().as_ref());
}
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.emit("host-close-requested", ());
}
})
.invoke_handler(tauri::generate_handler![
host_capabilities,
editor_capabilities,
workspace_choose,
workspace_recent,
workspace_revoke,
workspace_tree,
workspace_read,
workspace_write,
workspace_rename,
workspace_delete,
workspace_mkdir
])
.run(tauri::generate_context!())
.expect("桌面 Host 启动失败");
}
+621
View File
@@ -0,0 +1,621 @@
//! 每个 Vault 一个 OS 锁和 SQLite 日志;恢复只重放摘要仍匹配的写入,绝不覆盖外部修改。
use fs2::FileExt;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Serialize)]
pub struct HostError {
pub code: String,
pub message: String,
}
pub type Result<T> = std::result::Result<T, HostError>;
impl HostError {
fn new(code: &str) -> Self {
Self {
code: code.into(),
message: code.into(),
}
}
}
impl From<std::io::Error> for HostError {
fn from(_: std::io::Error) -> Self {
Self::new("FILESYSTEM_ERROR")
}
}
impl From<rusqlite::Error> for HostError {
fn from(_: rusqlite::Error) -> Self {
Self::new("DATABASE_ERROR")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub file_id: String,
pub path: String,
pub hash: String,
pub revision: i64,
pub deleted: bool,
#[serde(default)]
pub is_folder: bool,
}
#[derive(Serialize)]
pub struct Document {
#[serde(flatten)]
pub entry: Entry,
pub content: String,
}
pub fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn linked(path: &Path) -> std::io::Result<bool> {
let metadata = fs::symlink_metadata(path)?;
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
// junction 也是 reparse point,不能仅检查 symlink。
Ok(metadata.file_attributes() & 0x400 != 0)
}
#[cfg(not(windows))]
{
Ok(metadata.file_type().is_symlink())
}
}
pub struct Workspace {
pub root: PathBuf,
pub vault_id: String,
db: Connection,
_lock: File,
}
impl Workspace {
pub fn open(root: &Path) -> Result<Self> {
if !root.is_dir() || linked(root)? || root.to_string_lossy().starts_with("\\\\") {
return Err(HostError::new("VAULT_PATH_UNSUPPORTED"));
}
let root = root.canonicalize()?;
let managed = root.join(".ainote");
if managed.exists() && linked(&managed)? {
return Err(HostError::new("UNSAFE_PATH"));
}
fs::create_dir_all(&managed)?;
let lock_path = managed.join("host.lock");
if lock_path.exists() && linked(&lock_path)? {
return Err(HostError::new("UNSAFE_PATH"));
}
let lock = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(lock_path)?;
lock.try_lock_exclusive()
.map_err(|_| HostError::new("VAULT_ALREADY_OPEN"))?;
let db_path = managed.join("host.sqlite3");
if db_path.exists() && linked(&db_path)? {
return Err(HostError::new("UNSAFE_PATH"));
}
let db = Connection::open(db_path)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version > 1 {
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
}
db.execute_batch("BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS identity (id TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY,path TEXT UNIQUE NOT NULL,hash TEXT NOT NULL,revision INTEGER NOT NULL,deleted INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS journal (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,path TEXT NOT NULL,expected TEXT NOT NULL,content BLOB NOT NULL,origin TEXT NOT NULL,state TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS file_ops (id TEXT PRIMARY KEY,kind TEXT NOT NULL,path TEXT NOT NULL,destination TEXT NOT NULL,hash TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending');
CREATE TABLE IF NOT EXISTS outbox (operation_id TEXT PRIMARY KEY,file_id TEXT NOT NULL,revision INTEGER NOT NULL,path TEXT NOT NULL,hash TEXT NOT NULL,operation TEXT NOT NULL,content BLOB NOT NULL,state TEXT NOT NULL DEFAULT 'pending');
PRAGMA user_version=1; COMMIT;")?;
let vault_id: String = db
.query_row("SELECT id FROM identity", [], |r| r.get(0))
.optional()?
.unwrap_or_else(|| Uuid::new_v4().to_string());
db.execute(
"INSERT INTO identity SELECT ?1 WHERE NOT EXISTS (SELECT 1 FROM identity)",
[&vault_id],
)?;
let mut workspace = Self {
root,
vault_id,
db,
_lock: lock,
};
workspace.recover()?;
workspace.scan()?;
Ok(workspace)
}
pub fn resolve(&self, relative: &str) -> Result<PathBuf> {
if relative.is_empty() || relative.contains('\\') || relative.starts_with('/') {
return Err(HostError::new("UNSAFE_PATH"));
}
let mut path = self.root.clone();
for component in Path::new(relative).components() {
let Component::Normal(value) = component else {
return Err(HostError::new("UNSAFE_PATH"));
};
let name = value.to_string_lossy();
let stem = name.split('.').next().unwrap_or("").to_ascii_uppercase();
if name.eq_ignore_ascii_case(".ainote")
|| name.eq_ignore_ascii_case(".git")
|| name.ends_with(['.', ' '])
|| name
.chars()
.any(|c| c.is_control() || "<>:\"|?*".contains(c))
|| ["CON", "PRN", "AUX", "NUL"].contains(&stem.as_str())
|| (stem.len() == 4
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
&& stem.as_bytes()[3].is_ascii_digit())
{
return Err(HostError::new("UNSAFE_PATH"));
}
path.push(value);
if path.exists() && linked(&path)? {
return Err(HostError::new("UNSAFE_PATH"));
}
}
Ok(path)
}
fn entry(&self, path: &str) -> Result<Option<Entry>> {
Ok(self
.db
.query_row(
"SELECT id,path,hash,revision,deleted FROM files WHERE path=?1",
[path],
|r| {
Ok(Entry {
file_id: r.get(0)?,
path: r.get(1)?,
hash: r.get(2)?,
revision: r.get(3)?,
deleted: r.get(4)?,
is_folder: false,
})
},
)
.optional()?)
}
fn scan_dir(&self, dir: &Path, paths: &mut Vec<String>) -> Result<()> {
for item in fs::read_dir(dir)? {
let path = item?.path();
let relative = path
.strip_prefix(&self.root)
.map_err(|_| HostError::new("UNSAFE_PATH"))?
.to_string_lossy()
.replace('\\', "/");
if relative
.split('/')
.any(|s| s.eq_ignore_ascii_case(".ainote") || s.eq_ignore_ascii_case(".git"))
|| linked(&path)?
{
continue;
}
if path.is_dir() {
paths.push(relative);
self.scan_dir(&path, paths)?;
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
{
paths.push(relative);
}
}
Ok(())
}
pub fn scan(&mut self) -> Result<Vec<Entry>> {
let mut paths = Vec::new();
self.scan_dir(&self.root, &mut paths)?;
let mut entries = Vec::new();
for path in paths {
if self.resolve(&path)?.is_dir() {
entries.push(Entry {
file_id: format!("folder:{path}"),
path,
hash: String::new(),
revision: 0,
deleted: false,
is_folder: true,
});
continue;
}
let content = fs::read(self.resolve(&path)?)?;
let digest = hash(&content);
let previous = self.entry(&path)?;
if previous
.as_ref()
.is_none_or(|e| e.hash != digest || e.deleted)
{
let id = previous
.as_ref()
.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id.clone());
self.db.execute("INSERT INTO files VALUES (?1,?2,?3,1,0) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,revision=files.revision+1,deleted=0", params![id,path,digest])?;
}
entries.push(
self.entry(&path)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?,
);
}
Ok(entries)
}
pub fn read(&mut self, path: &str) -> Result<Document> {
self.scan()?;
let entry = self
.entry(path)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
let content = fs::read_to_string(self.resolve(path)?)?;
if hash(content.as_bytes()) != entry.hash {
return Err(HostError::new("REVISION_CONFLICT"));
}
Ok(Document { entry, content })
}
pub fn write(
&mut self,
path: &str,
expected: &str,
content: &[u8],
origin: &str,
) -> Result<Entry> {
if content.len() > 100 * 1024 * 1024 {
return Err(HostError::new("FILE_TOO_LARGE"));
}
if origin != "local" && origin != "remote" {
return Err(HostError::new("INVALID_ORIGIN"));
}
let target = self.resolve(path)?;
let current = if target.exists() {
hash(&fs::read(&target)?)
} else {
String::new()
};
if current != expected {
return Err(HostError::new("REVISION_CONFLICT"));
}
let file_id = self
.entry(path)?
.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id);
let operation_id = Uuid::new_v4().to_string();
self.db.execute(
"INSERT INTO journal VALUES (?1,?2,?3,?4,?5,?6,'pending')",
params![operation_id, file_id, path, expected, content, origin],
)?;
self.apply_journal(&operation_id, &file_id, path, expected, content, origin)?;
self.entry(path)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))
}
fn apply_journal(
&mut self,
operation_id: &str,
file_id: &str,
path: &str,
expected: &str,
content: &[u8],
origin: &str,
) -> Result<()> {
let target = self.resolve(path)?;
let digest = hash(content);
let current = if target.exists() {
hash(&fs::read(&target)?)
} else {
String::new()
};
if current != expected && current != digest {
self.db.execute(
"UPDATE journal SET state='conflict' WHERE operation_id=?1",
[operation_id],
)?;
return Err(HostError::new("RECOVERY_CONFLICT"));
}
if current != digest {
let parent = target
.parent()
.ok_or_else(|| HostError::new("UNSAFE_PATH"))?;
fs::create_dir_all(parent)?;
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
temp.write_all(content)?;
temp.as_file().sync_all()?;
temp.persist(&target)
.map_err(|_| HostError::new("ATOMIC_REPLACE_FAILED"))?;
#[cfg(unix)]
File::open(parent)?.sync_all()?;
}
// 文件成功但 DB 未提交时,重启凭 journal 补齐同一 operation_id,避免丢 outbox。
let tx = self.db.transaction()?;
tx.execute("INSERT INTO files VALUES (?1,?2,?3,1,0) ON CONFLICT(path) DO UPDATE SET hash=excluded.hash,revision=files.revision+1,deleted=0", params![file_id,path,digest])?;
if origin == "local" {
tx.execute("INSERT OR IGNORE INTO outbox SELECT ?1,id,revision,path,hash,'put',?2,'pending' FROM files WHERE path=?3", params![operation_id,content,path])?;
}
tx.execute("DELETE FROM journal WHERE operation_id=?1", [operation_id])?;
tx.commit()?;
Ok(())
}
pub fn recover(&mut self) -> Result<()> {
let operations = {
let mut statement = self
.db
.prepare("SELECT id FROM file_ops WHERE state='pending'")?;
let values = statement
.query_map([], |r| r.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
values
};
for operation in operations {
match self.apply_file_op(&operation) {
Err(error) if error.code == "RECOVERY_CONFLICT" => {}
result => result?,
}
}
let pending = {
let mut statement = self.db.prepare("SELECT operation_id,file_id,path,expected,content,origin FROM journal WHERE state='pending'")?;
let result = statement
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, Vec<u8>>(4)?,
r.get::<_, String>(5)?,
))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
result
};
for (op, id, path, expected, content, origin) in pending {
match self.apply_journal(&op, &id, &path, &expected, &content, &origin) {
Err(e) if e.code == "RECOVERY_CONFLICT" => {}
result => result?,
}
}
Ok(())
}
pub fn pending_count(&self) -> Result<i64> {
Ok(self.db.query_row(
"SELECT COUNT(*) FROM outbox WHERE state='pending'",
[],
|r| r.get(0),
)?)
}
pub fn mkdir(&self, path: &str) -> Result<()> {
fs::create_dir_all(self.resolve(path)?)?;
Ok(())
}
pub fn rename(&mut self, path: &str, destination: &str, expected: &str) -> Result<Entry> {
let target = self.resolve(destination)?;
if target.exists() {
return Err(HostError::new("PATH_CONFLICT"));
}
let operation = self.prepare_file_op("rename", path, destination, expected)?;
self.apply_file_op(&operation)?;
self.entry(destination)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))
}
pub fn delete(&mut self, path: &str, expected: &str) -> Result<()> {
let operation = self.prepare_file_op("delete", path, "", expected)?;
self.apply_file_op(&operation)
}
fn prepare_file_op(
&mut self,
kind: &str,
path: &str,
destination: &str,
expected: &str,
) -> Result<String> {
let source = self.resolve(path)?;
if !source.is_file() {
return Err(HostError::new("FILE_NOT_FOUND"));
}
let content = fs::read(source)?;
if hash(&content) != expected {
return Err(HostError::new("REVISION_CONFLICT"));
}
self.entry(path)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
let id = Uuid::new_v4().to_string();
self.db.execute(
"INSERT INTO file_ops VALUES (?1,?2,?3,?4,?5,?6,'pending')",
params![id, kind, path, destination, expected, content],
)?;
Ok(id)
}
fn apply_file_op(&mut self, id: &str) -> Result<()> {
let (kind, path, destination, expected, content): (
String,
String,
String,
String,
Vec<u8>,
) = self.db.query_row(
"SELECT kind,path,destination,hash,content FROM file_ops WHERE id=?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)?;
let source = self.resolve(&path)?;
let previous = self
.entry(&path)?
.ok_or_else(|| HostError::new("FILE_NOT_FOUND"))?;
let source_conflict = source.exists() && hash(&fs::read(&source)?) != expected;
let target = if kind == "rename" {
self.resolve(&destination)?
} else {
let trash = self.root.join(".ainote").join("trash");
if trash.exists() && linked(&trash)? {
return Err(HostError::new("UNSAFE_PATH"));
}
fs::create_dir_all(&trash)?;
trash.join(id)
};
let target_conflict =
target.exists() && (linked(&target)? || hash(&fs::read(&target)?) != expected);
if source_conflict || target_conflict {
self.db
.execute("UPDATE file_ops SET state='conflict' WHERE id=?1", [id])?;
return Err(HostError::new("RECOVERY_CONFLICT"));
}
// journal 保留完整内容,目标刷盘后才删除来源;两处崩溃均可幂等重放。
if !target.exists() {
let parent = target
.parent()
.ok_or_else(|| HostError::new("UNSAFE_PATH"))?;
fs::create_dir_all(parent)?;
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
temp.write_all(&content)?;
temp.as_file().sync_all()?;
temp.persist_noclobber(&target)
.map_err(|_| HostError::new("PATH_CONFLICT"))?;
}
if source.exists() {
fs::remove_file(source)?;
}
let tx = self.db.transaction()?;
if kind == "rename" {
tx.execute(
"UPDATE files SET path=?1,revision=revision+1 WHERE id=?2",
params![destination, previous.file_id],
)?;
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,hash,'put',?2,'pending' FROM files WHERE id=?3", params![id,content,previous.file_id])?;
} else {
tx.execute(
"UPDATE files SET deleted=1,revision=revision+1 WHERE id=?1",
[&previous.file_id],
)?;
tx.execute("INSERT INTO outbox SELECT ?1,id,revision,path,'','delete',X'','pending' FROM files WHERE id=?2", params![id,previous.file_id])?;
}
tx.execute("DELETE FROM file_ops WHERE id=?1", [id])?;
tx.commit()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rename_and_delete_recover_after_source_removed() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
let original = ws.write("a.md", "", b"safe", "local").unwrap();
let operation = ws
.prepare_file_op("rename", "a.md", "b.md", &original.hash)
.unwrap();
fs::rename(dir.path().join("a.md"), dir.path().join("b.md")).unwrap();
ws.apply_file_op(&operation).unwrap();
assert_eq!(ws.read("b.md").unwrap().entry.file_id, original.file_id);
let operation = ws
.prepare_file_op("delete", "b.md", "", &original.hash)
.unwrap();
fs::remove_file(dir.path().join("b.md")).unwrap();
ws.apply_file_op(&operation).unwrap();
assert_eq!(
fs::read(dir.path().join(".ainote/trash").join(operation)).unwrap(),
b"safe"
);
assert_eq!(ws.pending_count().unwrap(), 3);
}
#[test]
fn saves_conflict_and_reopen() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
let first = ws.write("中文/笔记.md", "", b"first", "local").unwrap();
assert!(Workspace::open(dir.path()).is_err());
assert_eq!(
ws.write("中文/笔记.md", "", b"lost", "local")
.unwrap_err()
.code,
"REVISION_CONFLICT"
);
let second = ws
.write("中文/笔记.md", &first.hash, b"second", "local")
.unwrap();
assert_eq!(first.file_id, second.file_id);
assert_eq!(ws.pending_count().unwrap(), 2);
drop(ws);
let mut ws = Workspace::open(dir.path()).unwrap();
assert_eq!(ws.read("中文/笔记.md").unwrap().content, "second");
assert_eq!(ws.pending_count().unwrap(), 2);
}
#[test]
fn unsafe_paths_external_change_and_remote_origin() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
for path in ["../x", "/x", "x\\y", "CON.md", ".ainote/file", "a:b", "x."] {
assert!(ws.resolve(path).is_err(), "{path}");
}
let first = ws.write("a.md", "", b"a", "remote").unwrap();
assert_eq!(ws.pending_count().unwrap(), 0);
fs::write(dir.path().join("a.md"), b"external").unwrap();
assert_eq!(
ws.write("a.md", &first.hash, b"lost", "local")
.unwrap_err()
.code,
"REVISION_CONFLICT"
);
}
#[test]
fn crash_after_file_replace_recovers_outbox_once() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
let id = Uuid::new_v4().to_string();
ws.db
.execute(
"INSERT INTO journal VALUES ('operation',?1,'a.md','',?2,'local','pending')",
params![id, b"recovered".as_slice()],
)
.unwrap();
fs::write(dir.path().join("a.md"), b"recovered").unwrap();
ws.recover().unwrap();
ws.recover().unwrap();
assert_eq!(ws.pending_count().unwrap(), 1);
assert_eq!(ws.read("a.md").unwrap().content, "recovered");
}
#[test]
fn recovery_keeps_both_sides_on_external_change() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
ws.db
.execute(
"INSERT INTO journal VALUES ('operation','file','a.md','',?1,'local','pending')",
[b"pending".as_slice()],
)
.unwrap();
fs::write(dir.path().join("a.md"), b"external").unwrap();
ws.recover().unwrap();
assert_eq!(fs::read(dir.path().join("a.md")).unwrap(), b"external");
let state: String = ws
.db
.query_row("SELECT state FROM journal", [], |r| r.get(0))
.unwrap();
assert_eq!(state, "conflict");
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "NotesAgent Preview",
"version": "0.3.0-alpha.1",
"identifier": "cc.kronecker.notesagent",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1200, "height": 800, "minWidth": 640, "minHeight": 480}],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"capabilities": ["main"]
}
},
"bundle": {"active": false}
}
@@ -63,7 +63,8 @@ describe('EditorPane file switching', () => {
wrapper = mount(EditorPane, { attachTo: document.body })
await nextTick()
const textarea = wrapper.get('textarea')
await vi.waitFor(() => expect(wrapper!.find('.cm-content').exists()).toBe(true))
const textarea = wrapper.get('.cm-content')
expect(textarea.attributes('spellcheck')).toBe('true')
expect(textarea.attributes('lang')).toBe('en')
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
+3 -16
View File
@@ -1,36 +1,23 @@
<script setup lang="ts">
import { defineAsyncComponent, ref, watch } from 'vue'
import { defineAsyncComponent, ref } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import EditorScrollButtons from './EditorScrollButtons.vue'
const SourceMarkdownEditor = defineAsyncComponent(() => import('./SourceMarkdownEditor.vue'))
const VisualMarkdownEditor = defineAsyncComponent(() => import('./VisualMarkdownEditor.vue'))
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
const themeStore = useThemeStore()
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
const container = ref<HTMLElement | null>(null)
watch(() => editorStore.headingRequest, request => {
const input = sourceEditor.value
if (!request || !input || request.path !== editorStore.currentFilePath) return
input.focus()
input.setSelectionRange(request.offset, request.offset)
const lines = input.value.slice(0, request.offset).split('\n').length - 1
input.scrollTop = lines * (parseFloat(getComputedStyle(input).lineHeight) || 24)
})
function updateContent(event: Event) {
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
}
</script>
<template>
<div ref="container" class="editor-scroll-pane">
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
:initial-content="editorStore.content" />
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
<SourceMarkdownEditor v-else :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}`" :initial-content="editorStore.content" />
<EditorScrollButtons :container="container" :content="editorStore.content" />
</div>
</template>
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import SourceMarkdownEditor from './SourceMarkdownEditor.vue'
import { executeEditorCommand } from '@/services/editorCommandService'
import { useEditorStore } from '@/stores/editor'
import * as workspace from '@/services/workspaceService'
let wrapper: VueWrapper | undefined
const original = '***\ntitle: 中文\ntags: [一, 二, 一]\ncustom: [1, false]\n---\n# 正文\n'
beforeEach(async () => {
localStorage.clear(); setActivePinia(createPinia())
vi.spyOn(workspace, 'readFileContent').mockResolvedValue(original)
vi.spyOn(workspace, 'getNoteId').mockResolvedValue('note-fixture')
vi.spyOn(workspace, 'saveFileContent').mockResolvedValue()
const store = useEditorStore(); store.setMode('source'); await store.loadFile('/fixture.md')
wrapper = mount(SourceMarkdownEditor, { props: { initialContent: store.content }, attachTo: document.body })
})
afterEach(() => { wrapper?.unmount(); useEditorStore().closeFile(); vi.restoreAllMocks() })
it('完整属性转换是一笔可撤销重做的源码事务', async () => {
const store = useEditorStore()
expect(await executeEditorCommand('editor.import-note-properties')).toEqual({ ok: true })
expect(store.content).toMatch(/^---\n/)
const converted = store.content
expect(store.saveStatus).toBe('dirty')
expect(await executeEditorCommand('editor.undo')).toEqual({ ok: true })
expect(store.content).toBe(original)
expect(await executeEditorCommand('editor.redo')).toEqual({ ok: true })
expect(store.content).toBe(converted)
})
it('保存失败保留转换后的内存正文,仍可撤销', async () => {
const store = useEditorStore()
vi.mocked(workspace.saveFileContent).mockRejectedValue(new Error('fixture disk full'))
await executeEditorCommand('editor.import-note-properties')
await store.save()
expect(store.saveStatus).toBe('save_failed')
expect(store.content).toMatch(/^---/)
await executeEditorCommand('editor.undo')
expect(store.content).toBe(original)
})
it('冲突文档禁用命令,保持原始内容', async () => {
const store = useEditorStore(); store.saveStatus = 'conflict'
expect(await executeEditorCommand('editor.import-note-properties')).toMatchObject({ ok: false, reason: 'unavailable' })
expect(store.content).toBe(original)
})
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { EditorState, Compartment } from '@codemirror/state'
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap, isolateHistory, undo, redo } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { registerEditorCommands } from '@/services/editorCommandService'
import { previewPropertyImport, type PropertyChoices, type PropertyConflict } from './importProperties'
import AppDialog from '@/components/common/AppDialog.vue'
const props = defineProps<{ initialContent: string }>()
const editor = useEditorStore(), settings = useSettingsStore()
const root = ref<HTMLElement | null>(null), error = ref('')
const conflicts = ref<PropertyConflict[]>([]), choices = ref<PropertyChoices>({})
const proofing = new Compartment()
let view: EditorView | undefined, dispose: (() => void) | undefined
let pending: { content: string; path: string | null; from: number; to: number; state: EditorState } | undefined
function attributes() {
return EditorView.contentAttributes.of({ spellcheck: String(settings.spellCheck), lang: settings.language,
'aria-label': settings.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器' })
}
function available() { return !!view && !!editor.currentFilePath && !['conflict', 'external_changed'].includes(editor.saveStatus) }
function importProperties() {
if (!available() || !view) return { ok: false as const, reason: 'unavailable' as const }
error.value = ''; choices.value = {}
const selection = view.state.selection.main
pending = { content: view.state.doc.toString(), path: editor.currentFilePath, from: selection.from, to: selection.to, state: view.state }
try {
const preview = previewPropertyImport(pending.content, selection)
if (preview.conflicts.length) conflicts.value = preview.conflicts
else applyImport()
return { ok: true as const }
} catch (reason) {
error.value = reason instanceof Error ? reason.message : String(reason)
pending = undefined
return { ok: false as const, reason: 'failed' as const }
}
}
function applyImport() {
if (!pending || !view) return
// 弹窗期间文档或路径改变就取消,不能把旧预览写入新笔记或新版本。
if (editor.currentFilePath !== pending.path || !view.state.doc.eq(pending.state.doc) || editor.content !== pending.content || !available()) {
pending = undefined; conflicts.value = []; error.value = '文档已经变化,请重新导入。'; return
}
try {
const result = previewPropertyImport(pending.content, { from: pending.from, to: pending.to }, choices.value)
if (result.content === null) return
if (result.content !== pending.content) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: result.content }, annotations: isolateHistory.of('full') })
}
pending = undefined; conflicts.value = []; view.focus()
} catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) }
}
onMounted(() => {
view = new EditorView({ parent: root.value!, state: EditorState.create({ doc: props.initialContent, extensions: [
history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), markdown(), proofing.of(attributes()),
EditorView.lineWrapping,
EditorView.updateListener.of(update => {
if (update.docChanged) { editor.updateContent(update.state.doc.toString()); editor.scheduleAutoSave(settings.autoSaveInterval) }
}),
EditorView.theme({ '&': { height: '100%', color: 'var(--color-text-primary)', backgroundColor: 'var(--color-background-primary)' },
'.cm-scroller': { fontFamily: 'var(--font-editor-mono)', fontSize: 'var(--font-editor-size)', overflow: 'auto' },
'.cm-gutters': { backgroundColor: 'var(--color-background-secondary)', color: 'var(--color-text-secondary)', border: 'none' },
'.cm-content': { padding: '24px 8px', minHeight: '100%' } }),
] }) })
dispose = registerEditorCommands({ available, handlers: {
'editor.import-note-properties': importProperties,
'editor.undo': () => undo(view!) ? { ok: true } : { ok: false, reason: 'unavailable' },
'editor.redo': () => redo(view!) ? { ok: true } : { ok: false, reason: 'unavailable' },
} })
})
watch(() => [settings.spellCheck, settings.language], () => view?.dispatch({ effects: proofing.reconfigure(attributes()) }))
watch(() => editor.headingRequest, request => {
if (!view || !request || request.path !== editor.currentFilePath) return
const offset = Math.min(view.state.doc.length, request.offset)
view.dispatch({ selection: { anchor: offset }, effects: EditorView.scrollIntoView(offset, { y: 'start' }) }); view.focus()
})
onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
</script>
<template>
<div class="source-container">
<div class="source-actions"><button class="btn" :disabled="!editor.currentFilePath || ['conflict', 'external_changed'].includes(editor.saveStatus)" @click="importProperties">导入为笔记属性</button></div>
<p v-if="error" role="alert">{{ error }}</p>
<div ref="root" class="source-code" />
<AppDialog v-if="conflicts.length" label="属性冲突预览" @close="conflicts = []; pending = undefined">
<h2>选择要保留的属性</h2>
<div v-for="conflict in conflicts" :key="conflict.key">
<strong>{{ conflict.key }}</strong><pre>已有{{ conflict.current }}
导入{{ conflict.incoming }}</pre>
<label>保留哪一侧 <select v-model="choices[conflict.key]"><option disabled value="">请选择</option><option value="current">已有属性</option><option value="incoming">导入属性</option></select></label>
</div>
<button class="btn btn-primary" :disabled="conflicts.some(item => !choices[item.key])" @click="applyImport">作为一次编辑应用</button>
</AppDialog>
</div>
</template>
<style scoped>
.source-container { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.source-code { flex: 1; min-height: 0; overflow: hidden; }
.source-actions { padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { parseDocument } from 'yaml'
import { previewPropertyImport } from './importProperties'
describe('笔记属性导入事务预览', () => {
it('历史格式规范化,保留复杂字段、顺序标签与行为配置', () => {
const source = '***\ntitle: 中文\ntags: [笔记, "空 格", 笔记]\nembedding_local_only: true\ncustom:\n nested: [1, false]\n---\n# 正文'
const result = previewPropertyImport(source).content!
expect(result.startsWith('---\n')).toBe(true)
expect(result).toContain('embedding_local_only: true')
expect(result).toContain('nested: [ 1, false ]')
expect(result).toContain('# 正文')
expect(previewPropertyImport(result).content).toBe(result)
})
it('字段冲突必须明确选择,未选时无候选内容', () => {
const source = '---\ntitle: 原标题\n---\n\n***\ntitle: 新标题\ntags: a,b\n---\n正文'
const from = source.indexOf('***'), to = source.lastIndexOf('正文')
expect(previewPropertyImport(source, { from, to }).content).toBeNull()
const result = previewPropertyImport(source, { from, to }, { title: 'incoming' }).content!
expect(result).toContain('title: 新标题')
expect(result.match(/^---$/gm)).toHaveLength(2)
})
it('普通分隔线、正文和代码不能误判', () => {
for (const source of ['---\n普通正文\n---', '正文: 值', '***\nother: body\n---']) expect(() => previewPropertyImport(source)).toThrow()
const source = '```yaml\n---\ntitle: example\n---\n```'
expect(() => previewPropertyImport(source, { from: 8, to: source.length - 3 })).toThrow('代码块')
})
it('单块锚点保持别名语义;非法重复键不改内容', () => {
const source = '---\ntitle: 标题\ncustom: &value [1, 2]\ncopy: *value\n---\n正文'
const output = previewPropertyImport(source).content!
const yaml = output.split('---')[1]!
const value = parseDocument(yaml).toJS()
expect(value.copy).toEqual([1, 2])
expect(() => previewPropertyImport('---\ntitle: a\ntitle: b\n---')).toThrow()
})
})
@@ -0,0 +1,80 @@
/** 保留 YAML 节点、未知字段及类型;有歧义时只返回预览,不改正文。 */
import { isMap, isScalar, isSeq, parseDocument, type Document } from 'yaml'
export interface PropertyConflict { key: string; current: string; incoming: string }
export interface ImportPreview { content: string | null; conflicts: PropertyConflict[] }
export type PropertyChoices = Record<string, 'current' | 'incoming'>
function block(source: string) {
const match = source.match(/^\uFEFF?(---|\*\*\*)[ \t]*\r?\n([\s\S]*?)\r?\n(?:---+|\.\.\.)[ \t]*(?:\r?\n|$)/)
if (!match) return null
const document = parseDocument(match[2]!, { uniqueKeys: true })
if (document.errors.length || document.warnings.length || !isMap(document.contents)) throw new Error('属性 YAML 无法无损处理,请保留原文在源码中修改。')
if (match[1] === '***' && !document.has('title') && !document.has('tags')) return null
return { prefix: match[0], document }
}
function normalizeTags(document: Document) {
if (!document.has('tags')) return
const previous = document.get('tags', true)
let values: string[]
if (isScalar(previous) && typeof previous.value === 'string') values = previous.value.split(/[,]/).map(value => value.trim()).filter(Boolean)
else if (isScalar(previous) && previous.value === null) values = []
else if (isSeq(previous) && previous.items.every(item => isScalar(item) && typeof item.value === 'string' && !item.anchor)) values = previous.items.map(item => String((item as { value: string }).value))
else throw new Error('标签结构不支持无损转换,已保留原文。')
const replacement = document.createNode([...new Set(values)])
if (isScalar(previous) || isSeq(previous)) {
replacement.anchor = previous.anchor; replacement.comment = previous.comment; replacement.commentBefore = previous.commentBefore
}
document.set('tags', replacement)
}
export function previewPropertyImport(source: string, selection?: { from: number; to: number }, choices: PropertyChoices = {}): ImportPreview {
if (source.length > 5 * 1024 * 1024) throw new Error('文档过大,请缩小属性选区。')
const selected = selection && selection.from !== selection.to ? selection : undefined
const start = selected?.from ?? 0, end = selected?.to ?? source.length
if (start < 0 || end > source.length || start >= end) throw new Error('选区无效')
// 选区必须从完整行开始,且不能位于代码围栏内。
if (start && source[start - 1] !== '\n') throw new Error('请选择完整属性块')
let fence: string | null = null
for (const line of source.slice(0, start).split(/\r?\n/)) {
const marker = line.match(/^ {0,3}(`{3,}|~{3,})/)
if (marker) {
if (!fence) fence = marker[1]!
else if (marker[1]![0] === fence[0] && marker[1]!.length >= fence.length) fence = null
}
}
if (fence) throw new Error('代码块中的文本不会作为笔记属性导入')
const incoming = block(source.slice(start, end))
if (!incoming) throw new Error('未识别到完整的标准或历史属性块')
if (selected && source.slice(start + incoming.prefix.length, end).trim()) throw new Error('选区包含属性块以外的正文')
const existing = start > 0 ? block(source) : null
if (existing && start < existing.prefix.length) throw new Error('选区与已有属性块重叠')
const document = existing ? existing.document.clone() : incoming.document.clone()
const conflicts: PropertyConflict[] = []
if (existing) {
// 跨文档别名的归属不明确,不能在合并时悄悄改变指向。
if (/[&*][\w-]+/.test(incoming.document.toString()) || /[&*][\w-]+/.test(existing.document.toString())) throw new Error('含 YAML 锚点的多个属性块请先在源码中合并')
for (const pair of (incoming.document.contents as NonNullable<typeof incoming.document.contents> & { items: { key: unknown }[] }).items) {
if (!isScalar(pair.key) || typeof pair.key.value !== 'string') throw new Error('属性键必须是字符串')
const key = pair.key.value
const node = incoming.document.get(key, true)
if (document.has(key) && JSON.stringify(document.get(key)) !== JSON.stringify(incoming.document.get(key))) {
conflicts.push({ key, current: String(document.get(key, true)), incoming: String(node) })
if (!choices[key]) continue
if (choices[key] === 'current') continue
}
document.set(key, node)
}
}
if (conflicts.some(conflict => !choices[conflict.key])) return { content: null, conflicts }
normalizeTags(document)
// 校验别名引用数量和最终文档,无法解析时不返回候选正文。
document.toJS({ maxAliasCount: 50 })
const body = existing
? source.slice(existing.prefix.length, start) + source.slice(start + incoming.prefix.length)
: source.slice(0, start) + source.slice(start + incoming.prefix.length)
const newline = source.includes('\r\n') ? '\r\n' : '\n'
const prefix = `---\n${document.toString()}---\n`.replace(/\n/g, newline)
return { content: (source.startsWith('\uFEFF') ? '\uFEFF' : '') + prefix + body.replace(/^\uFEFF/, ''), conflicts }
}
+6 -4
View File
@@ -8,6 +8,7 @@ import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@elemen
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient'
import { isDesktop } from '@/services/platform/desktop'
const router = useRouter()
const workspaceStore = useWorkspaceStore()
@@ -28,7 +29,7 @@ async function initializeVault() {
try { await workspaceStore.loadRecentVaults() }
catch (reason) { openError.value = reason instanceof Error ? reason.message : String(reason); return }
const lastVaultPath = localStorage.getItem('last-vault-path')
if (settingsStore.restoreLastVault && lastVaultPath) {
if (!isDesktop() && settingsStore.restoreLastVault && lastVaultPath) {
await openVault(lastVaultPath)
}
}
@@ -50,6 +51,7 @@ async function openVault(path: string) {
}
async function openFolderPicker() {
if (isDesktop()) { await openVault(''); return }
const configured = workspaceStore.recentVaults[0]
if (configured) await openVault(configured.path)
}
@@ -70,7 +72,7 @@ async function openFolderPicker() {
<div v-if="openError" class="error-banner" role="alert">{{ openError }} <button class="btn" @click="initializeVault" :disabled="isLoading">{{ t('重试', 'Retry') }}</button></div>
<p v-if="isLoading" role="status">{{ t('正在打开知识库', 'Opening knowledge base') }}</p>
<h2 class="card-title">{{ t('选择知识库', 'Select Knowledge Base') }}</h2>
<p class="card-desc">{{ t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
<p class="card-desc">{{ isDesktop() ? t('桌面预览:选择本地目录;AI 与同步尚未接通。', 'Desktop preview: choose a local folder. AI and sync are not connected yet.') : t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
<div class="section-label">{{ t('最近打开', 'Recently opened') }}</div>
@@ -93,8 +95,8 @@ async function openFolderPicker() {
</div>
<div class="actions">
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
<AppIcon :icon="FolderOpened" /> {{ t('打开后端 Vault', 'Open backend Vault') }}
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || (!isDesktop() && !workspaceStore.recentVaults.length)">
<AppIcon :icon="FolderOpened" /> {{ isDesktop() ? t('选择本地 Vault', 'Choose local Vault') : t('打开后端 Vault', 'Open backend Vault') }}
</button>
</div>
+2
View File
@@ -10,6 +10,7 @@ import { useSettingsStore } from './stores/settings'
import { watch } from 'vue'
import { appLocale } from './i18n'
import { updateDocumentTitle } from './router'
import { installDesktopLifecycle } from './services/platform/lifecycle'
const app = createApp(App)
const pinia = createPinia()
@@ -27,3 +28,4 @@ watch(() => settingsStore.spellCheck, (enabled) => {
}, { immediate: true })
app.mount('#app')
void installDesktopLifecycle()
+2
View File
@@ -1,4 +1,5 @@
import type { ApiError, ErrorResponse } from '@/contracts'
import { isDesktop } from './platform/desktop'
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
@@ -27,6 +28,7 @@ export class ApiErrorClass extends Error {
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
if (isDesktop()) throw new ApiErrorClass('CORE_UNAVAILABLE', '桌面 AI Core 尚未接通;本地编辑可继续。')
const { params, token, headers, timeoutMs, ...rest } = options
const controller = timeoutMs ? new AbortController() : null
let timedOut = false
@@ -1,4 +1,5 @@
/** Versioned frontend boundary for future native menus/shortcuts; no Tauri IPC yet. */
/** 活动编辑器命令边界;原生菜单复用能力检测和处理器。 */
import { hostInvoke, isDesktop } from './platform/desktop'
export const editorCommandVersion = 1
export const editorCommandIds = [
'editor.bold', 'editor.italic', 'editor.strikethrough', 'editor.inline-code',
@@ -19,7 +20,11 @@ let active: Target | undefined
export function registerEditorCommands(target: Target) {
active = target
return () => { if (active === target) active = undefined }
updateNativeEditorMenu()
return () => { if (active === target) { active = undefined; updateNativeEditorMenu() } }
}
export function updateNativeEditorMenu() {
if (isDesktop()) void hostInvoke('editor_capabilities', { importEnabled: !!active?.handlers['editor.import-note-properties'] && active.available() }).catch(() => undefined)
}
export function getEditorCommandCapabilities() {
return editorCommandIds.map(id => ({ id, supported: !!active?.handlers[id], enabled: !!active?.handlers[id] && active.available() }))
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => ({ enabled: false, invoke: vi.fn() }))
vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => native.enabled, invoke: native.invoke }))
import { contentHash, DesktopError, hostInvoke, nativeTree } from './desktop'
import * as workspace from '../workspaceService'
beforeEach(() => { native.enabled = false; native.invoke.mockReset() })
describe('原生 Workspace 适配', () => {
it('Web 不伪造原生能力', async () => {
await expect(hostInvoke('workspace_tree')).rejects.toThrow('DESKTOP_UNAVAILABLE')
expect(native.invoke).not.toHaveBeenCalled()
})
it('分层目录保留稳定文件身份', () => {
const tree = nativeTree([{ file_id: 'stable', path: '中文/笔记.md', hash: 'h', revision: 2, deleted: false }])
expect(tree[0]?.children?.[0]).toMatchObject({ id: 'stable', note_id: 'stable', path: '/中文/笔记.md' })
})
it('保存使用原始内存基线摘要且不调用 HTTP', async () => {
native.enabled = true
native.invoke.mockResolvedValue({})
await workspace.saveFileContent('/中文.md', 'new', 'old')
expect(native.invoke).toHaveBeenCalledWith('workspace_write', { path: '中文.md', content: 'new', expected: await contentHash('old') })
await expect(workspace.saveFileContent('/中文.md', 'new')).rejects.toThrow('EXPECTED_REVISION_REQUIRED')
})
it('冲突保留结构化错误,不变成保存成功', async () => {
native.enabled = true; native.invoke.mockRejectedValue('REVISION_CONFLICT')
await expect(hostInvoke('workspace_write')).rejects.toBeInstanceOf(DesktopError)
})
it('取消原生目录选择不进入空 Vault', async () => {
native.enabled = true; native.invoke.mockResolvedValue(null)
await expect(workspace.openVault('ignored')).rejects.toThrow()
})
})
+47
View File
@@ -0,0 +1,47 @@
/** 平台能力集中检测;Web 模式永不回退到虚构的原生数据。 */
import { invoke, isTauri } from '@tauri-apps/api/core'
import type { FileNode } from '@/contracts'
export const isDesktop = () => isTauri()
export interface HostEntry { file_id: string; path: string; hash: string; revision: number; deleted: boolean; is_folder?: boolean }
export interface HostDocument extends HostEntry { content: string }
export interface HostVault { vault_id: string; path: string; name: string }
export interface HostCapabilities { protocol: number; workspace: boolean; core: boolean; sync: boolean; credentials: boolean; extensions: boolean; release: string }
export class DesktopError extends Error {
constructor(public code: string) { super(code); this.name = 'DesktopError' }
}
export async function hostInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (!isDesktop()) throw new DesktopError('DESKTOP_UNAVAILABLE')
try { return await invoke<T>(command, args) }
catch (error) { throw new DesktopError(typeof error === 'string' ? error : 'HOST_ERROR') }
}
export function nativePath(path: string) { return path.replace(/^\//, '') }
export function nativeTree(entries: HostEntry[]): FileNode[] {
const roots: FileNode[] = []
const folders = new Map<string, FileNode>()
for (const entry of entries) {
if (entry.deleted) continue
const parts = entry.path.split('/')
let children = roots, path = ''
for (const name of (entry.is_folder ? parts : parts.slice(0, -1))) {
path += `/${name}`
let node = folders.get(path)
if (!node) {
node = { id: `folder:${path}`, path, name, type: 'folder', children: [] }
folders.set(path, node); children.push(node)
}
children = node.children!
}
if (!entry.is_folder) children.push({ id: entry.file_id, note_id: entry.file_id, path: `/${entry.path}`, name: parts.at(-1)!, type: 'file' })
}
return roots
}
export async function contentHash(content: string) {
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content))))
.map(value => value.toString(16).padStart(2, '0')).join('')
}
@@ -0,0 +1,24 @@
/** 原生命令复用活动编辑器边界;保存失败时保持窗口及内存内容。 */
import { listen } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEditorStore } from '@/stores/editor'
import { executeEditorCommand, updateNativeEditorMenu } from '@/services/editorCommandService'
import { watch } from 'vue'
import { isDesktop } from './desktop'
export async function installDesktopLifecycle() {
if (!isDesktop()) return
const activeEditor = useEditorStore()
watch(() => [activeEditor.currentFilePath, activeEditor.saveStatus, activeEditor.mode], updateNativeEditorMenu, { flush: 'post' })
await listen<string>('editor-command', event => { void executeEditorCommand(event.payload) })
let closing = false
await listen('host-close-requested', async () => {
if (closing) return
closing = true
try {
const editor = useEditorStore()
if (['dirty', 'saving', 'save_failed'].includes(editor.saveStatus)) await editor.save()
if (['saved', 'idle'].includes(editor.saveStatus)) await getCurrentWindow().destroy()
} finally { closing = false }
})
}
+57
View File
@@ -10,6 +10,7 @@ import apiClient from './apiClient'
import { t } from '@/i18n'
import * as noteService from './noteService'
import { splitNoteMetadata } from '@/utils/noteMetadata'
import { contentHash, hostInvoke, isDesktop, nativePath, nativeTree, type HostDocument, type HostEntry, type HostVault } from './platform/desktop'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
export interface VaultInfo {
@@ -80,15 +81,28 @@ async function requireNoteId(filePath: string): Promise<string> {
}
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
if (isDesktop()) {
const vault = (await getRecentVaults())[0]
if (!vault) throw new Error('VAULT_NOT_OPEN')
const entries = await hostInvoke<HostEntry[]>('workspace_tree')
return { ...vault, file_count: entries.length, indexed_note_count: 0, requires_refresh: false }
}
return apiClient.get('/api/workspace', { timeoutMs: 15000 })
}
export async function getRecentVaults(): Promise<VaultInfo[]> {
if (isDesktop()) return hostInvoke<HostVault[]>('workspace_recent')
const workspace = await getWorkspaceInfo()
return [{ vault_id: workspace.vault_id, path: workspace.path, name: workspace.name }]
}
export async function openVault(path: string): Promise<VaultInfo> {
if (isDesktop()) {
const vault = await hostInvoke<HostVault | null>('workspace_choose')
if (!vault) throw new Error(t('已取消选择', 'Selection cancelled'))
cachedTree = null; noteIdByPath.clear(); typeByPath.clear(); treeRequestVersion++
return vault
}
treeRequestVersion++
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
cacheEntries(snapshot.items)
@@ -107,6 +121,14 @@ export async function createVault(path: string, name: string): Promise<VaultInfo
export async function refreshTree(): Promise<FileNode[]> {
const version = ++treeRequestVersion
if (isDesktop()) {
const entries = await hostInvoke<HostEntry[]>('workspace_tree')
if (version !== treeRequestVersion) return cachedTree ?? []
noteIdByPath.clear(); typeByPath.clear()
for (const entry of entries) { if (!entry.is_folder) noteIdByPath.set(`/${entry.path}`, entry.file_id); typeByPath.set(`/${entry.path}`, entry.is_folder ? 'folder' : 'file') }
cachedTree = nativeTree(entries)
return cachedTree
}
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree', { timeoutMs: 10000 })
if (version !== treeRequestVersion) return cachedTree ?? []
return cacheEntries(entries)
@@ -117,6 +139,7 @@ export async function getFileTree(): Promise<FileNode[]> {
}
export async function readFileContent(filePath: string): Promise<string> {
if (isDesktop()) return (await hostInvoke<HostDocument>('workspace_read', { path: nativePath(filePath) })).content
const note = await noteService.getNote(await requireNoteId(filePath))
return note.markdown
}
@@ -127,6 +150,11 @@ export async function getNoteId(filePath: string): Promise<string> {
}
export async function saveFileContent(filePath: string, content: string, expectedContent?: string): Promise<void> {
if (isDesktop()) {
if (expectedContent === undefined) throw new Error('EXPECTED_REVISION_REQUIRED')
await hostInvoke('workspace_write', { path: nativePath(filePath), expected: await contentHash(expectedContent), content })
return
}
const metadata = splitNoteMetadata(content)
const expectedHash = expectedContent === undefined ? undefined : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(expectedContent)))).map(byte => byte.toString(16).padStart(2, '0')).join('')
await noteService.updateNote(await requireNoteId(filePath), {
@@ -142,6 +170,12 @@ export async function createFile(
name: string,
content = '',
): Promise<FileNode> {
if (isDesktop()) {
const path = [nativePath(folderPath), name.endsWith('.md') ? name : `${name}.md`].filter(Boolean).join('/')
const entry = await hostInvoke<HostEntry>('workspace_write', { path, expected: '', content })
noteIdByPath.set(`/${path}`, entry.file_id)
return { id: entry.file_id, note_id: entry.file_id, path: `/${path}`, name: path.split('/').at(-1)!, type: 'file' }
}
const title = name.replace(/\.md$/i, '')
const note = await noteService.createNote({
title,
@@ -152,6 +186,11 @@ export async function createFile(
}
export async function createFolder(parentPath: string, name: string): Promise<FileNode> {
if (isDesktop()) {
const path = [nativePath(parentPath), name].filter(Boolean).join('/')
await hostInvoke('workspace_mkdir', { path })
return { id: `folder:/${path}`, path: `/${path}`, name, type: 'folder', children: [] }
}
const entry = await apiClient.post<ApiWorkspaceEntry>('/api/workspace/folders', {
parent: relativePath(parentPath),
name,
@@ -160,6 +199,12 @@ export async function createFolder(parentPath: string, name: string): Promise<Fi
}
export async function renameFile(oldPath: string, newName: string): Promise<void> {
if (isDesktop()) {
const path = nativePath(oldPath)
const document = await hostInvoke<HostDocument>('workspace_read', { path })
await hostInvoke('workspace_rename', { path, destination: [...path.split('/').slice(0, -1), newName].join('/'), expected: document.hash })
await refreshTree(); return
}
const path = normalizePublicPath(oldPath)
if (typeByPath.get(path) === 'folder') {
await apiClient.post('/api/workspace/folders/rename', {
@@ -173,6 +218,12 @@ export async function renameFile(oldPath: string, newName: string): Promise<void
}
export async function deleteFile(pathValue: string): Promise<void> {
if (isDesktop()) {
const path = nativePath(pathValue)
const document = await hostInvoke<HostDocument>('workspace_read', { path })
await hostInvoke('workspace_delete', { path, expected: document.hash })
await refreshTree(); return
}
const path = normalizePublicPath(pathValue)
if (typeByPath.get(path) === 'folder') {
await apiClient.post<OperationResponse>('/api/workspace/folders/delete', {
@@ -185,6 +236,12 @@ export async function deleteFile(pathValue: string): Promise<void> {
}
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
if (isDesktop()) {
const path = nativePath(sourcePath)
const document = await hostInvoke<HostDocument>('workspace_read', { path })
await hostInvoke('workspace_rename', { path, destination: [nativePath(targetPath), path.split('/').at(-1)].filter(Boolean).join('/'), expected: document.hash })
await refreshTree(); return
}
const source = normalizePublicPath(sourcePath)
if (typeByPath.get(source) !== 'file') {
throw new Error(t('当前阶段只支持移动笔记文件。', 'Only note files can be moved at this stage.'))
+2 -1
View File
@@ -4,6 +4,7 @@ import type { SaveStatus } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient'
import { DesktopError } from '@/services/platform/desktop'
export const useEditorStore = defineStore('editor', () => {
const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
@@ -71,7 +72,7 @@ export const useEditorStore = defineStore('editor', () => {
lastSavedAt.value = new Date().toISOString()
}
} catch (error) {
if (currentFilePath.value === targetPath) saveStatus.value = error instanceof ApiErrorClass && error.code === 'NOTE_CONTENT_CONFLICT' ? 'conflict' : 'save_failed'
if (currentFilePath.value === targetPath) saveStatus.value = (error instanceof ApiErrorClass && error.code === 'NOTE_CONTENT_CONFLICT') || (error instanceof DesktopError && error.code === 'REVISION_CONFLICT') ? 'conflict' : 'save_failed'
} finally {
pendingSave = null
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()