// 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, pub documents: Vec, 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, 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, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ImportDisposition { Added, Existing, Conflict, } #[tauri::command] pub async fn get_knowledge_snapshot(app: AppHandle) -> Result { 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 { 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, 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 { 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, 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::>(); 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 { 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 { 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 { // 新建空白页:标题撞名自动补序号,落进本机库并进 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, 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, 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, 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), 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 { 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 { 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::::new(); let mut documents = Vec::::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, ) -> 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::, _>>() .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 { 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 { 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, 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 { 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::, _>>() .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::(); 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 == "" { 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("
", "\n") .replace("
", "\n") .replace("
", "\n"); out.push_str(&strip_notion_links(&cleaned)); out.push('\n'); } out } /// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)。 fn strip_notion_links(line: &str) -> String { let bytes: Vec = 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 { 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 { 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 { 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 { 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::() .trim() .to_string(); if cleaned.is_empty() { "未命名文件夹".into() } else { cleaned } } fn relative_posix(root: &Path, path: &Path) -> Result { 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 { 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, "星湖"); } }