2026-08-15 15:02:35 +08:00
|
|
|
|
// 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;
|
2026-08-16 00:02:05 +08:00
|
|
|
|
#[cfg(unix)]
|
2026-08-15 15:02:35 +08:00
|
|
|
|
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};
|
2026-08-16 00:02:05 +08:00
|
|
|
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
2026-08-15 15:02:35 +08:00
|
|
|
|
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}"))?
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 00:02:05 +08:00
|
|
|
|
#[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}"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 15:02:35 +08:00
|
|
|
|
#[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-16 00:02:05 +08:00
|
|
|
|
// 冰朔 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);
|
|
|
|
|
|
}
|
2026-08-15 15:02:35 +08:00
|
|
|
|
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> {
|
2026-08-16 18:25:57 +08:00
|
|
|
|
let native = crate::authenticated_storage::account_storage_root(app, "knowledge-v1")?;
|
2026-08-15 15:02:35 +08:00
|
|
|
|
ensure_native_knowledge_root(&native)?;
|
2026-08-16 18:25:57 +08:00
|
|
|
|
// 旧全局知识库不得自动投影给任一新登录账号;后续只允许用户显式迁移。
|
|
|
|
|
|
Ok((native, None))
|
2026-08-15 15:02:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 00:02:05 +08:00
|
|
|
|
/// 写私有文件的跨平台选项: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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 15:02:35 +08:00
|
|
|
|
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}"))?;
|
2026-08-16 00:02:05 +08:00
|
|
|
|
#[cfg(unix)]
|
2026-08-15 15:02:35 +08:00
|
|
|
|
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()));
|
2026-08-16 00:02:05 +08:00
|
|
|
|
let mut file = private_file_options()
|
2026-08-15 15:02:35 +08:00
|
|
|
|
.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(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 00:02:05 +08:00
|
|
|
|
/// 外来页面进门即洗:只留文字,外壳全换成光湖原生 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 15:02:35 +08:00
|
|
|
|
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() {
|
2026-08-16 00:02:05 +08:00
|
|
|
|
"md" | "markdown" => convert_to_native_markdown(&raw),
|
2026-08-15 15:02:35 +08:00
|
|
|
|
"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
|
|
|
|
|
|
},
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
2026-08-16 00:02:05 +08:00
|
|
|
|
let mut file = private_file_options()
|
2026-08-15 15:02:35 +08:00
|
|
|
|
.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}"))?;
|
2026-08-16 00:02:05 +08:00
|
|
|
|
let mut file = private_file_options()
|
2026-08-15 15:02:35 +08:00
|
|
|
|
.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, "星湖");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|