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

1378 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, "星湖");
}
}