hololake-system-architecture/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs

1378 lines
48 KiB
Rust
Raw Normal View History

// SPDX-License-Identifier: AGPL-3.0-or-later
// Clean-room native migration of the HoloLake Era knowledge-workspace contract.
use ring::digest::{digest, SHA256};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs::{self, OpenOptions};
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::time::UNIX_EPOCH;
use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use uuid::Uuid;
const SNAPSHOT_SCHEMA: &str = "hololake.native-knowledge-base/v1";
const MAX_TREE_DOCUMENTS: usize = 5_000;
const MAX_IMPORT_FILES: usize = 1_000;
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
const MAX_READ_BYTES: u64 = 2 * 1024 * 1024;
const MAX_SEARCH_RESULTS: usize = 100;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeDocumentSummary {
pub source: &'static str,
pub path: String,
pub title: String,
pub updated_at_unix_ms: u128,
pub size_bytes: u64,
pub content_sha256: String,
pub duplicate_count: usize,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeSnapshot {
pub schema: &'static str,
pub state: &'static str,
pub native_root: String,
pub legacy_available: bool,
pub legacy_root: Option<String>,
pub documents: Vec<KnowledgeDocumentSummary>,
pub raw_document_count: usize,
pub unique_document_count: usize,
pub duplicate_document_count: usize,
pub truncated: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReadKnowledgeDocumentInput {
pub source: String,
pub path: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeDocument {
pub source: String,
pub path: String,
pub title: String,
pub body: String,
pub updated_at_unix_ms: u128,
pub content_sha256: String,
pub writable: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SaveKnowledgeDocumentInput {
pub path: String,
pub body: String,
pub expected_content_sha256: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeSaveResult {
pub schema: &'static str,
pub state: &'static str,
pub git_commit: String,
pub document: KnowledgeDocument,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SearchKnowledgeInput {
pub query: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeSearchResult {
pub source: &'static str,
pub path: String,
pub title: String,
pub snippet: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeImportResult {
pub schema: &'static str,
pub state: &'static str,
pub source_name: String,
pub destination: String,
pub imported_documents: usize,
pub imported_assets: usize,
pub existing_documents: usize,
pub conflicts: usize,
pub skipped: usize,
pub failed: usize,
pub first_document: Option<String>,
pub git_commit: String,
pub snapshot: KnowledgeSnapshot,
}
#[derive(Default)]
struct ImportCounts {
documents: usize,
assets: usize,
existing: usize,
conflicts: usize,
skipped: usize,
failed: usize,
first_document: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ImportDisposition {
Added,
Existing,
Conflict,
}
#[tauri::command]
pub async fn get_knowledge_snapshot(app: AppHandle) -> Result<KnowledgeSnapshot, String> {
let roots = knowledge_roots(&app)?;
tauri::async_runtime::spawn_blocking(move || snapshot_at(&roots.0, roots.1.as_deref()))
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn read_knowledge_document(
app: AppHandle,
input: ReadKnowledgeDocumentInput,
) -> Result<KnowledgeDocument, String> {
let roots = knowledge_roots(&app)?;
tauri::async_runtime::spawn_blocking(move || {
read_document_at(&roots.0, roots.1.as_deref(), input)
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn search_knowledge(
app: AppHandle,
input: SearchKnowledgeInput,
) -> Result<Vec<KnowledgeSearchResult>, String> {
let roots = knowledge_roots(&app)?;
tauri::async_runtime::spawn_blocking(move || search_at(&roots.0, roots.1.as_deref(), input))
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
}
#[tauri::command]
pub async fn save_knowledge_document(
app: AppHandle,
input: SaveKnowledgeDocumentInput,
) -> Result<KnowledgeSaveResult, String> {
let roots = knowledge_roots(&app)?;
tauri::async_runtime::spawn_blocking(move || save_document_at(&roots.0, input))
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportKnowledgeDocumentInput {
pub title: String,
pub body: String,
#[serde(default = "default_export_extension")]
pub extension: String,
}
fn default_export_extension() -> String {
"md".to_string()
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportKnowledgeDocumentReceipt {
pub path: String,
pub bytes: u64,
}
#[tauri::command]
pub async fn export_knowledge_document(
app: AppHandle,
input: ExportKnowledgeDocumentInput,
) -> Result<Option<ExportKnowledgeDocumentReceipt>, String> {
// 下载本页Rust 侧弹保存对话框人选好位置后原样写出md/html 全平台通用格式)。
let extension = match input.extension.as_str() {
"html" => "html",
_ => "md",
};
// 文件名清洗按最严平台Windows九个禁用字符全洗结尾的点号空格也修。
let mut suggested = input
.title
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "·");
suggested = suggested.trim_end_matches(['.', ' ']).to_string();
if suggested.trim().is_empty() {
suggested = "知识页".to_string();
}
// Windows 保留设备名CON/NUL/COM1…不能直接当文件名撞名就补个尾巴。
if matches!(suggested.to_ascii_uppercase().as_str(),
"CON" | "PRN" | "AUX" | "NUL"
| "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
| "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9") {
suggested.push_str("·页");
}
suggested.push('.');
suggested.push_str(extension);
let filter_label = if extension == "html" { "网页文件" } else { "Markdown" };
let (sender, receiver) = std::sync::mpsc::channel::<Option<PathBuf>>();
app.dialog()
.file()
.set_file_name(&suggested)
.add_filter(filter_label, &[extension])
.save_file(move |selection| {
let picked = selection.as_ref().and_then(|path| path.as_path()).map(|path| path.to_path_buf());
let _ = sender.send(picked);
});
let picked = tauri::async_runtime::spawn_blocking(move || receiver.recv().ok().flatten())
.await
.map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))?;
let Some(target) = picked else {
return Ok(None);
};
let bytes = input.body.len() as u64;
let path = target.display().to_string();
tauri::async_runtime::spawn_blocking(move || {
fs::write(&target, input.body.as_bytes())
.map_err(|error| format!("HOLOLAKE_EXPORT_WRITE_FAILED: {error}"))
})
.await
.map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))??;
Ok(Some(ExportKnowledgeDocumentReceipt { path, bytes }))
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteKnowledgeDocumentInput {
pub source: String,
pub path: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeDeleteReceipt {
pub removed: String,
pub pages: usize,
}
fn resolve_native_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
let mut target = root.to_path_buf();
for component in Path::new(relative).components() {
match component {
Component::Normal(part) => target.push(part),
_ => return Err("HOLOLAKE_KNOWLEDGE_PATH_INVALID".into()),
}
}
Ok(target)
}
async fn confirm_destructive(app: &AppHandle, message: String) -> Result<bool, String> {
let confirmer = app.clone();
let confirmed = tauri::async_runtime::spawn_blocking(move || {
confirmer
.dialog()
.message(message)
.title("删除确认")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom("确认删除".to_string(), "取消".to_string()))
.blocking_show()
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?;
Ok(confirmed)
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateKnowledgeDocumentInput {
pub title: String,
}
#[tauri::command]
pub async fn create_knowledge_document(
app: AppHandle,
input: CreateKnowledgeDocumentInput,
) -> Result<KnowledgeDeleteReceipt, String> {
// 新建空白页:标题撞名自动补序号,落进本机库并进 Git 托管。
let roots = knowledge_roots(&app)?;
let base = if input.title.trim().is_empty() { "未命名页面".to_string() } else { input.title.trim().to_string() };
let docs = roots.0.join("docs");
let mut name = base.clone();
let mut counter = 2u32;
while docs.join(format!("{name}.md")).exists() {
name = format!("{base} {counter}");
counter += 1;
}
let target = docs.join(format!("{name}.md"));
let body = format!("# {name}\n\n");
let receipt_name = name.clone();
tauri::async_runtime::spawn_blocking(move || {
let mut file = private_file_options()
.open(&target)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
file.write_all(body.as_bytes())
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
file.sync_all().map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
git(&roots.0, &["add", "--", &format!("docs/{name}.md")], "ADD")?;
git(&roots.0, &["commit", "-m", &format!("新建页面:{name}")], "COMMIT")?;
Ok::<(), String>(())
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_JOIN_FAILED: {error}"))??;
Ok(KnowledgeDeleteReceipt { removed: format!("{receipt_name}.md"), pages: 1 })
}
#[tauri::command]
pub async fn delete_knowledge_document(
app: AppHandle,
input: DeleteKnowledgeDocumentInput,
) -> Result<Option<KnowledgeDeleteReceipt>, String> {
// 只删本机库native供体源只读不碰。
if input.source != "native" {
return Err("HOLOLAKE_KNOWLEDGE_DELETE_READ_ONLY_SOURCE".into());
}
let roots = knowledge_roots(&app)?;
let target = resolve_native_path(&roots.0, &input.path)?;
if !target.is_file() {
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_NOT_FOUND".into());
}
let title = target
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("这一页")
.to_string();
if !confirm_destructive(&app, format!("把页面「{title}」移到回收站?随时可以找回。")).await? {
return Ok(None);
}
move_to_trash(&roots.0, &target)?;
Ok(Some(KnowledgeDeleteReceipt { removed: input.path, pages: 1 }))
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteKnowledgeFolderInput {
pub folder: String,
}
#[tauri::command]
pub async fn delete_knowledge_folder(
app: AppHandle,
input: DeleteKnowledgeFolderInput,
) -> Result<Option<KnowledgeDeleteReceipt>, String> {
// 删整个文件夹 = 删本机库里该相对目录及其全部页面。
let roots = knowledge_roots(&app)?;
let target = resolve_native_path(&roots.0, &input.folder)?;
if target == roots.0 || !target.is_dir() {
return Err("HOLOLAKE_KNOWLEDGE_FOLDER_NOT_FOUND".into());
}
let name = target
.file_name()
.and_then(OsStr::to_str)
.unwrap_or(&input.folder)
.to_string();
let pages = fs::read_dir(&target)
.map(|entries| {
entries
.flatten()
.filter(|entry| entry.file_type().map(|t| t.is_file()).unwrap_or(false))
.count()
})
.unwrap_or(0);
if !confirm_destructive(&app, format!("把文件夹「{name}」和里面全部 {pages} 个页面移到回收站?随时可以找回。")).await? {
return Ok(None);
}
move_to_trash(&roots.0, &target)?;
Ok(Some(KnowledgeDeleteReceipt { removed: input.folder, pages }))
}
#[tauri::command]
pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> {
// PDF 走系统打印通道:打印框里选"存储为 PDF"即得 PDF 文件。
let window = app
.get_webview_window("main")
.ok_or_else(|| "HOLOLAKE_PRINT_WINDOW_NOT_FOUND".to_string())?;
window.print().map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}"))
}
#[tauri::command]
pub async fn select_and_import_knowledge_folder(
app: AppHandle,
) -> Result<Option<KnowledgeImportResult>, String> {
let picker = app.clone();
let selected = tauri::async_runtime::spawn_blocking(move || {
picker
.dialog()
.file()
.set_title("导入到 HoloLake 知识库")
.blocking_pick_folder()
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_JOIN_FAILED: {error}"))?;
let Some(selected) = selected else {
return Ok(None);
};
let source = selected
.into_path()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_PATH_INVALID: {error}"))?;
// 冰朔 2026-08-15 谕:知识库是一个整体,进门即转成光湖原生格式。
let confirmer = app.clone();
let confirmed = tauri::async_runtime::spawn_blocking(move || {
confirmer
.dialog()
.message("导入的页面将原生转成光湖知识库格式,原有知识库的页面逻辑渲染,以及页面跳转关系将失效,是否确认导入转换?")
.title("导入转换确认")
.kind(MessageDialogKind::Info)
.buttons(MessageDialogButtons::OkCancelCustom("确认导入转换".to_string(), "取消".to_string()))
.blocking_show()
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?;
if !confirmed {
return Ok(None);
}
let roots = knowledge_roots(&app)?;
tauri::async_runtime::spawn_blocking(move || {
import_folder_at(&roots.0, roots.1.as_deref(), &source).map(Some)
})
.await
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_JOIN_FAILED: {error}"))?
}
fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option<PathBuf>), String> {
let native = crate::authenticated_storage::account_storage_root(app, "knowledge-v1")?;
ensure_native_knowledge_root(&native)?;
// 旧全局知识库不得自动投影给任一新登录账号;后续只允许用户显式迁移。
Ok((native, None))
}
/// 写私有文件的跨平台选项unix 上只许本人读写Windows 上不做额外权限设置。
fn private_file_options() -> OpenOptions {
let mut options = OpenOptions::new();
options.create_new(true).write(true);
#[cfg(unix)]
options.mode(0o600);
options
}
/// 删除 = 搬到库内回收站(.trash绝不真删名字带时间戳防撞车。
fn move_to_trash(root: &Path, target: &Path) -> Result<PathBuf, String> {
let relative = target
.strip_prefix(root)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
let destination = root.join(".trash").join(format!("{}-{stamp}", relative.display()));
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
}
fs::rename(target, &destination)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
Ok(destination)
}
fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> {
fs::create_dir_all(root.join("docs").join("导入"))
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?;
#[cfg(unix)]
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?;
if !root.join(".git").exists() {
git(root, &["init", "--initial-branch=main"], "INIT")?;
git(root, &["config", "user.name", "HoloLake"], "CONFIG")?;
git(
root,
&["config", "user.email", "local@hololake.invalid"],
"CONFIG",
)?;
}
Ok(())
}
fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot, String> {
let mut raw_documents = Vec::new();
collect_documents(
&native.join("docs"),
&native.join("docs"),
"native",
&mut raw_documents,
)?;
if let Some(root) = legacy {
collect_documents(
&root.join("docs"),
&root.join("docs"),
"legacy",
&mut raw_documents,
)?;
}
let raw_document_count = raw_documents.len();
let mut by_hash = HashMap::<String, usize>::new();
let mut documents = Vec::<KnowledgeDocumentSummary>::new();
for document in raw_documents {
if let Some(index) = by_hash.get(&document.content_sha256).copied() {
documents[index].duplicate_count += 1;
} else {
by_hash.insert(document.content_sha256.clone(), documents.len());
documents.push(document);
}
}
documents.sort_by(|left, right| {
left.source
.cmp(right.source)
.then_with(|| left.path.cmp(&right.path))
});
let truncated = raw_document_count >= MAX_TREE_DOCUMENTS;
documents.truncate(MAX_TREE_DOCUMENTS);
let unique_document_count = documents.len();
Ok(KnowledgeSnapshot {
schema: SNAPSHOT_SCHEMA,
state: "READY",
native_root: native.to_string_lossy().into_owned(),
legacy_available: legacy.is_some(),
legacy_root: legacy.map(|path| path.to_string_lossy().into_owned()),
documents,
raw_document_count,
unique_document_count,
duplicate_document_count: raw_document_count.saturating_sub(unique_document_count),
truncated,
})
}
fn collect_documents(
root: &Path,
current: &Path,
source: &'static str,
output: &mut Vec<KnowledgeDocumentSummary>,
) -> Result<(), String> {
if output.len() >= MAX_TREE_DOCUMENTS {
return Ok(());
}
let mut entries = fs::read_dir(current)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if output.len() >= MAX_TREE_DOCUMENTS {
break;
}
let kind = entry
.file_type()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?;
if kind.is_symlink() {
continue;
}
let path = entry.path();
let name = entry.file_name();
if kind.is_dir() {
if name != OsStr::new(".git") && name != OsStr::new(".hololake") {
collect_documents(root, &path, source, output)?;
}
continue;
}
if !kind.is_file() || !is_readable_document(&path) {
continue;
}
let metadata = entry
.metadata()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?;
if metadata.len() > MAX_READ_BYTES {
continue;
}
let relative = relative_posix(root, &path)?;
let bytes = fs::read(&path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?;
output.push(KnowledgeDocumentSummary {
source,
path: relative,
title: document_title(&path)?,
updated_at_unix_ms: modified_unix_ms(&metadata),
size_bytes: metadata.len(),
content_sha256: sha256_hex(&bytes),
duplicate_count: 0,
});
}
Ok(())
}
fn read_document_at(
native: &Path,
legacy: Option<&Path>,
input: ReadKnowledgeDocumentInput,
) -> Result<KnowledgeDocument, String> {
let root = source_docs_root(native, legacy, &input.source)?;
let path = safe_document_path(&root, &input.path)?;
let metadata = fs::metadata(&path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?;
if !metadata.is_file() || metadata.len() > MAX_READ_BYTES || !is_readable_document(&path) {
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNSUPPORTED".into());
}
let body = fs::read_to_string(&path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_INVALID_UTF8: {error}"))?;
let content_sha256 = sha256_hex(body.as_bytes());
let writable = input.source == "native";
Ok(KnowledgeDocument {
source: input.source,
path: input.path,
title: title_from_text(&body, &path),
body,
updated_at_unix_ms: modified_unix_ms(&metadata),
content_sha256,
writable,
})
}
fn save_document_at(
native: &Path,
input: SaveKnowledgeDocumentInput,
) -> Result<KnowledgeSaveResult, String> {
if input.body.len() as u64 > MAX_READ_BYTES
|| input.expected_content_sha256.len() != 64
|| !input
.expected_content_sha256
.chars()
.all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase())
{
return Err("HOLOLAKE_KNOWLEDGE_SAVE_INPUT_INVALID".into());
}
let root = native.join("docs");
let path = safe_document_path(&root, &input.path)?;
if !is_readable_document(&path) {
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNSUPPORTED".into());
}
let previous = fs::read(&path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?;
if sha256_hex(&previous) != input.expected_content_sha256 {
return Err("HOLOLAKE_KNOWLEDGE_SAVE_CONFLICT".into());
}
if previous != input.body.as_bytes() {
let temporary = path.with_extension(format!("save-{}.tmp", Uuid::new_v4()));
let mut file = private_file_options()
.open(&temporary)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?;
file.write_all(input.body.as_bytes())
.and_then(|_| file.sync_all())
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?;
fs::rename(&temporary, &path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?;
git(
native,
&["add", "--", &format!("docs/{}", input.path)],
"ADD",
)?;
git(
native,
&["commit", "-m", &format!("knowledge: update {}", input.path)],
"COMMIT",
)?;
}
let git_commit = git(native, &["rev-parse", "HEAD"], "READBACK")?
.trim()
.to_string();
let document = read_document_at(
native,
None,
ReadKnowledgeDocumentInput {
source: "native".into(),
path: input.path,
},
)?;
Ok(KnowledgeSaveResult {
schema: "hololake.knowledge-document-save/v1",
state: if previous == input.body.as_bytes() {
"UNCHANGED"
} else {
"SAVED"
},
git_commit,
document,
})
}
fn search_at(
native: &Path,
legacy: Option<&Path>,
input: SearchKnowledgeInput,
) -> Result<Vec<KnowledgeSearchResult>, String> {
let query = input.query.trim().to_lowercase();
if query.is_empty() || query.chars().count() > 200 {
return Err("HOLOLAKE_KNOWLEDGE_SEARCH_QUERY_INVALID".into());
}
let snapshot = snapshot_at(native, legacy)?;
let mut results = Vec::new();
for document in snapshot.documents {
if results.len() >= MAX_SEARCH_RESULTS {
break;
}
let root = source_docs_root(native, legacy, document.source)?;
let path = safe_document_path(&root, &document.path)?;
let body = fs::read_to_string(&path).unwrap_or_default();
let haystack = format!("{}\n{}", document.title, body).to_lowercase();
let Some(index) = haystack.find(&query) else {
continue;
};
let start = haystack[..index]
.char_indices()
.rev()
.nth(40)
.map(|(position, _)| position)
.unwrap_or(0);
let end = haystack[index..]
.char_indices()
.nth(120)
.map(|(position, _)| index + position)
.unwrap_or(haystack.len());
results.push(KnowledgeSearchResult {
source: document.source,
path: document.path,
title: document.title,
snippet: haystack[start..end].replace('\n', " "),
});
}
Ok(results)
}
fn import_folder_at(
native: &Path,
legacy: Option<&Path>,
source: &Path,
) -> Result<KnowledgeImportResult, String> {
let source = source
.canonicalize()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_SOURCE_UNAVAILABLE: {error}"))?;
if !source.is_dir() || source.starts_with(native) {
return Err("HOLOLAKE_KNOWLEDGE_IMPORT_SOURCE_INVALID".into());
}
let source_name = safe_segment(
source
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("未命名文件夹"),
);
let import_root = native.join("docs").join("导入");
let destination_name = source_name.clone();
let destination = import_root.join(&destination_name);
let destination_exists = destination.exists();
let staging = if destination_exists {
destination.clone()
} else {
let path =
native
.join(".import-staging")
.join(format!("{}-{}", destination_name, Uuid::new_v4()));
fs::create_dir_all(&path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
path
};
let mut counts = ImportCounts::default();
let import_result = walk_import(&source, &source, &staging, &destination_name, &mut counts);
if let Err(error) = import_result {
if !destination_exists {
let _ = fs::remove_dir_all(&staging);
}
return Err(error);
}
if counts.documents == 0 && counts.assets == 0 && counts.existing == 0 && counts.conflicts == 0
{
if !destination_exists {
let _ = fs::remove_dir_all(&staging);
}
return Err("HOLOLAKE_KNOWLEDGE_IMPORT_NO_SUPPORTED_FILES".into());
}
if !destination_exists {
fs::rename(&staging, &destination)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_COMMIT_FAILED: {error}"))?;
}
let relative_destination = format!("docs/导入/{destination_name}");
if counts.documents + counts.assets > 0 {
if let Err(error) =
git(native, &["add", "--", &relative_destination], "ADD").and_then(|_| {
git(
native,
&["commit", "-m", &format!("import: {source_name}")],
"COMMIT",
)
})
{
if !destination_exists {
let _ = fs::remove_dir_all(&destination);
}
let _ = git(native, &["reset", "--mixed"], "ROLLBACK_INDEX");
return Err(error);
}
}
let git_commit = git(native, &["rev-parse", "HEAD"], "READBACK")?
.trim()
.to_string();
Ok(KnowledgeImportResult {
schema: "hololake.knowledge-folder-import/v1",
state: if counts.conflicts > 0 {
"CONFLICTS_DETECTED"
} else if counts.documents + counts.assets == 0 {
"ALREADY_PRESENT"
} else {
"IMPORTED"
},
source_name,
destination: format!("导入/{destination_name}"),
imported_documents: counts.documents,
imported_assets: counts.assets,
existing_documents: counts.existing,
conflicts: counts.conflicts,
skipped: counts.skipped,
failed: counts.failed,
first_document: counts.first_document,
git_commit,
snapshot: snapshot_at(native, legacy)?,
})
}
fn walk_import(
root: &Path,
current: &Path,
staging: &Path,
destination_name: &str,
counts: &mut ImportCounts,
) -> Result<(), String> {
if counts.documents + counts.assets + counts.skipped + counts.failed >= MAX_IMPORT_FILES {
return Ok(());
}
let mut entries = fs::read_dir(current)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if counts.documents + counts.assets + counts.skipped + counts.failed >= MAX_IMPORT_FILES {
break;
}
let name = entry.file_name();
if name == OsStr::new(".DS_Store") || name.to_string_lossy().starts_with("._") {
continue;
}
let kind = entry
.file_type()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?;
if kind.is_symlink() {
counts.skipped += 1;
continue;
}
let path = entry.path();
if kind.is_dir() {
let lower = name.to_string_lossy().to_lowercase();
if [".git", "node_modules", ".idea", ".vscode", "__macosx"].contains(&lower.as_str()) {
counts.skipped += 1;
continue;
}
walk_import(root, &path, staging, destination_name, counts)?;
continue;
}
if !kind.is_file() {
counts.skipped += 1;
continue;
}
let metadata = entry
.metadata()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?;
if metadata.len() > MAX_FILE_BYTES {
counts.skipped += 1;
continue;
}
let relative = path
.strip_prefix(root)
.map_err(|_| "HOLOLAKE_KNOWLEDGE_IMPORT_PATH_INVALID".to_string())?;
let safe_relative = relative
.components()
.filter_map(|component| match component {
Component::Normal(value) => Some(safe_segment(&value.to_string_lossy())),
_ => None,
})
.collect::<PathBuf>();
let extension = path
.extension()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
let imported = if ["md", "markdown", "txt", "csv", "json", "yaml", "yml"]
.contains(&extension.as_str())
{
import_document(&path, &safe_relative, staging).map(|(target, disposition)| {
match disposition {
ImportDisposition::Added => counts.documents += 1,
ImportDisposition::Existing => counts.existing += 1,
ImportDisposition::Conflict => counts.conflicts += 1,
}
if disposition != ImportDisposition::Conflict {
counts.first_document.get_or_insert_with(|| {
format!(
"导入/{}/{}",
destination_name,
target.to_string_lossy().replace('\\', "/")
)
});
}
})
} else if ["png", "jpg", "jpeg", "gif", "webp", "svg"].contains(&extension.as_str()) {
let target = staging.join(&safe_relative);
import_asset(&path, &target).map(|disposition| match disposition {
ImportDisposition::Added => counts.assets += 1,
ImportDisposition::Existing => counts.existing += 1,
ImportDisposition::Conflict => counts.conflicts += 1,
})
} else {
counts.skipped += 1;
continue;
};
if imported.is_err() {
counts.failed += 1;
}
}
Ok(())
}
/// 外来页面进门即洗:只留文字,外壳全换成光湖原生 Markdown。
/// 原有知识库的渲染层与页面跳转关系在此失效(冰朔 2026-08-15 谕)。
fn convert_to_native_markdown(raw: &str) -> String {
let text = raw.replace("\r\n", "\n");
let mut out = String::with_capacity(text.len());
for line in text.split('\n') {
let trimmed = line.trim();
if trimmed == "<aside>" || trimmed == "</aside>" {
continue;
}
if let Some(title) = trimmed.strip_prefix(":::toggle") {
let title = title.trim();
out.push_str("**▸ ");
if title.is_empty() {
out.push_str("详情");
} else {
out.push_str(title);
}
out.push_str("**\n");
continue;
}
if trimmed == ":::" || trimmed.starts_with(":::toc") {
continue;
}
let cleaned = line
.replace("<br/>", "\n")
.replace("<br />", "\n")
.replace("<br>", "\n");
out.push_str(&strip_notion_links(&cleaned));
out.push('\n');
}
out
}
/// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)。
fn strip_notion_links(line: &str) -> String {
let bytes: Vec<char> = line.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == '[' {
if let Some(close) = bytes[i + 1..].iter().position(|c| *c == ']') {
let text_end = i + 1 + close;
if text_end + 1 < bytes.len() && bytes[text_end + 1] == '(' {
if let Some(paren_end) = bytes[text_end + 2..].iter().position(|c| *c == ')') {
let href: String = bytes[text_end + 2..text_end + 2 + paren_end].iter().collect();
if href.contains("notion.so") || href.contains("notion.site") {
out.extend(bytes[i + 1..text_end].iter());
i = text_end + 2 + paren_end + 1;
continue;
}
}
}
}
}
out.push(bytes[i]);
i += 1;
}
out
}
fn import_document(
source: &Path,
relative: &Path,
staging: &Path,
) -> Result<(PathBuf, ImportDisposition), String> {
let extension = source
.extension()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
let raw = fs::read_to_string(source)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_DOCUMENT_INVALID: {error}"))?;
let mut target_relative = relative.to_path_buf();
if extension != "md" {
target_relative.set_extension("md");
}
let title = source
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("未命名页面");
let content = match extension.as_str() {
"md" | "markdown" => convert_to_native_markdown(&raw),
"txt" => format!("# {title}\n\n{raw}\n"),
"csv" => format!("# {title}\n\n```csv\n{raw}\n```\n"),
"json" => format!("# {title}\n\n```json\n{raw}\n```\n"),
"yaml" | "yml" => format!("# {title}\n\n```yaml\n{raw}\n```\n"),
_ => return Err("HOLOLAKE_KNOWLEDGE_IMPORT_DOCUMENT_UNSUPPORTED".into()),
};
let target = staging.join(&target_relative);
fs::create_dir_all(target.parent().unwrap_or(staging))
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
if target.exists() {
let existing = fs::read(&target)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
return Ok((
target_relative,
if existing == content.as_bytes() {
ImportDisposition::Existing
} else {
ImportDisposition::Conflict
},
));
}
let mut file = private_file_options()
.open(&target)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
file.write_all(content.as_bytes())
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
file.sync_all()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
Ok((target_relative, ImportDisposition::Added))
}
fn import_asset(source: &Path, target: &Path) -> Result<ImportDisposition, String> {
let bytes = fs::read(source)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_ASSET_INVALID: {error}"))?;
if target.exists() {
let existing = fs::read(target)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
return Ok(if existing == bytes {
ImportDisposition::Existing
} else {
ImportDisposition::Conflict
});
}
fs::create_dir_all(target.parent().unwrap_or_else(|| Path::new(".")))
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
let mut file = private_file_options()
.open(target)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
Ok(ImportDisposition::Added)
}
fn sha256_hex(bytes: &[u8]) -> String {
digest(&SHA256, bytes)
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn source_docs_root(native: &Path, legacy: Option<&Path>, source: &str) -> Result<PathBuf, String> {
match source {
"native" => Ok(native.join("docs")),
"legacy" => legacy
.map(|root| root.join("docs"))
.ok_or_else(|| "HOLOLAKE_LEGACY_KNOWLEDGE_UNAVAILABLE".into()),
_ => Err("HOLOLAKE_KNOWLEDGE_SOURCE_INVALID".into()),
}
}
fn safe_document_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
let relative_path = Path::new(relative);
if relative_path.is_absolute()
|| relative_path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_INVALID".into());
}
let canonical_root = root
.canonicalize()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ROOT_UNAVAILABLE: {error}"))?;
let candidate = canonical_root.join(relative_path);
let canonical = candidate
.canonicalize()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?;
if !canonical.starts_with(&canonical_root) {
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_ESCAPE".into());
}
let metadata = fs::symlink_metadata(&candidate)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?;
if metadata.file_type().is_symlink() {
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_SYMLINK_DENIED".into());
}
Ok(canonical)
}
fn is_readable_document(path: &Path) -> bool {
matches!(
path.extension()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase()
.as_str(),
"md" | "markdown" | "txt"
)
}
fn document_title(path: &Path) -> Result<String, String> {
let body = fs::read_to_string(path)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_INVALID_UTF8: {error}"))?;
Ok(title_from_text(&body, path))
}
fn title_from_text(body: &str, path: &Path) -> String {
body.lines()
.find_map(|line| line.trim().strip_prefix("# ").map(str::trim))
.filter(|title| !title.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
path.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("未命名页面")
.to_string()
})
}
fn safe_segment(value: &str) -> String {
let cleaned = value
.chars()
.map(|character| {
if character.is_control() || "\\/:*?\"<>|".contains(character) {
'-'
} else {
character
}
})
.collect::<String>()
.trim()
.to_string();
if cleaned.is_empty() {
"未命名文件夹".into()
} else {
cleaned
}
}
fn relative_posix(root: &Path, path: &Path) -> Result<String, String> {
Ok(path
.strip_prefix(root)
.map_err(|_| "HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_INVALID".to_string())?
.to_string_lossy()
.replace('\\', "/"))
}
fn modified_unix_ms(metadata: &fs::Metadata) -> u128 {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
fn git(root: &Path, args: &[&str], operation: &str) -> Result<String, String> {
let output = Command::new("/usr/bin/git")
.current_dir(root)
.env("GIT_TERMINAL_PROMPT", "0")
.args(args)
.output()
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_GIT_{operation}_FAILED: {error}"))?;
if !output.status.success() {
return Err(format!(
"HOLOLAKE_KNOWLEDGE_GIT_{operation}_FAILED: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
String::from_utf8(output.stdout)
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_GIT_{operation}_INVALID_UTF8: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn snapshot_reads_native_and_legacy_without_merging_roots() {
let native = tempdir().unwrap();
let legacy = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
fs::create_dir_all(legacy.path().join("docs")).unwrap();
fs::write(native.path().join("docs/native.md"), "# 原生页面\n").unwrap();
fs::write(legacy.path().join("docs/legacy.md"), "# 旧版页面\n").unwrap();
let snapshot = snapshot_at(native.path(), Some(legacy.path())).unwrap();
assert_eq!(snapshot.documents.len(), 2);
assert!(snapshot
.documents
.iter()
.any(|item| item.source == "native"));
assert!(snapshot
.documents
.iter()
.any(|item| item.source == "legacy"));
}
#[test]
fn document_path_escape_is_rejected() {
let native = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
let error = read_document_at(
native.path(),
None,
ReadKnowledgeDocumentInput {
source: "native".into(),
path: "../secret.md".into(),
},
)
.unwrap_err();
assert!(error.contains("PATH_INVALID"));
}
#[test]
fn folder_import_is_git_backed_and_converts_text() {
let native = tempdir().unwrap();
let source = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
fs::write(source.path().join("readme.txt"), "hello").unwrap();
fs::write(source.path().join("skip.bin"), [1, 2, 3]).unwrap();
let result = import_folder_at(native.path(), None, source.path()).unwrap();
assert_eq!(result.state, "IMPORTED");
assert_eq!(result.imported_documents, 1);
assert_eq!(result.skipped, 1);
assert_eq!(result.git_commit.len(), 40);
let imported = native
.path()
.join("docs")
.join(&result.destination)
.join("readme.md");
assert!(fs::read_to_string(imported).unwrap().contains("# readme"));
}
#[test]
fn repeated_folder_import_is_idempotent_and_does_not_create_another_tree() {
let native = tempdir().unwrap();
let source_parent = tempdir().unwrap();
let source = source_parent.path().join("稳定知识源");
fs::create_dir_all(&source).unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
fs::write(source.join("page.md"), "# 唯一页面\n\n连续内容\n").unwrap();
let first = import_folder_at(native.path(), None, &source).unwrap();
let second = import_folder_at(native.path(), None, &source).unwrap();
assert_eq!(first.state, "IMPORTED");
assert_eq!(second.state, "ALREADY_PRESENT");
assert_eq!(second.imported_documents, 0);
assert_eq!(second.existing_documents, 1);
assert_eq!(first.destination, second.destination);
assert_eq!(second.snapshot.raw_document_count, 1);
assert_eq!(second.snapshot.unique_document_count, 1);
assert_eq!(
fs::read_dir(native.path().join("docs/导入"))
.unwrap()
.count(),
1
);
}
#[test]
fn snapshot_coalesces_exact_content_and_prefers_native_source() {
let native = tempdir().unwrap();
let legacy = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
fs::create_dir_all(legacy.path().join("docs")).unwrap();
fs::write(native.path().join("docs/native.md"), "# 同一内容\n").unwrap();
fs::write(legacy.path().join("docs/legacy.md"), "# 同一内容\n").unwrap();
let snapshot = snapshot_at(native.path(), Some(legacy.path())).unwrap();
assert_eq!(snapshot.raw_document_count, 2);
assert_eq!(snapshot.unique_document_count, 1);
assert_eq!(snapshot.duplicate_document_count, 1);
assert_eq!(snapshot.documents[0].source, "native");
assert_eq!(snapshot.documents[0].duplicate_count, 1);
}
#[test]
fn native_edit_requires_the_exact_current_hash_and_commits_the_update() {
let native = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
fs::write(native.path().join("docs/page.md"), "# 旧内容\n").unwrap();
git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap();
git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap();
let current = read_document_at(
native.path(),
None,
ReadKnowledgeDocumentInput {
source: "native".into(),
path: "page.md".into(),
},
)
.unwrap();
let saved = save_document_at(
native.path(),
SaveKnowledgeDocumentInput {
path: "page.md".into(),
body: "# 新内容\n".into(),
expected_content_sha256: current.content_sha256,
},
)
.unwrap();
assert_eq!(saved.state, "SAVED");
assert_eq!(saved.git_commit.len(), 40);
assert_eq!(saved.document.body, "# 新内容\n");
assert!(save_document_at(
native.path(),
SaveKnowledgeDocumentInput {
path: "page.md".into(),
body: "# 错误覆盖\n".into(),
expected_content_sha256: sha256_hex(b"# old stale value\n"),
},
)
.unwrap_err()
.contains("SAVE_CONFLICT"));
}
#[test]
fn search_returns_bounded_safe_projection() {
let native = tempdir().unwrap();
ensure_native_knowledge_root(native.path()).unwrap();
fs::write(native.path().join("docs/page.md"), "# 星湖\n\n连续存在").unwrap();
let results = search_at(
native.path(),
None,
SearchKnowledgeInput {
query: "连续".into(),
},
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "星湖");
}
}