feat: 零点原核频道v1+登录双门地基+五湖开场——zero_point裁决链/灯塔查号/code_repo_login/通知卡重设计/浅色主题联动(冰朔20260815夜谕)
This commit is contained in:
parent
ffed065841
commit
07fd4c85ef
16 changed files with 3082 additions and 88 deletions
|
|
@ -7,12 +7,13 @@ 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;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
use uuid::Uuid;
|
||||
|
||||
const SNAPSHOT_SCHEMA: &str = "hololake.native-knowledge-base/v1";
|
||||
|
|
@ -179,6 +180,233 @@ pub async fn save_knowledge_document(
|
|||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportKnowledgeDocumentInput {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
#[serde(default = "default_export_extension")]
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
fn default_export_extension() -> String {
|
||||
"md".to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportKnowledgeDocumentReceipt {
|
||||
pub path: String,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_knowledge_document(
|
||||
app: AppHandle,
|
||||
input: ExportKnowledgeDocumentInput,
|
||||
) -> Result<Option<ExportKnowledgeDocumentReceipt>, String> {
|
||||
// 下载本页:Rust 侧弹保存对话框,人选好位置后原样写出(md/html 全平台通用格式)。
|
||||
let extension = match input.extension.as_str() {
|
||||
"html" => "html",
|
||||
_ => "md",
|
||||
};
|
||||
// 文件名清洗按最严平台(Windows)来:九个禁用字符全洗,结尾的点号空格也修。
|
||||
let mut suggested = input
|
||||
.title
|
||||
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "·");
|
||||
suggested = suggested.trim_end_matches(['.', ' ']).to_string();
|
||||
if suggested.trim().is_empty() {
|
||||
suggested = "知识页".to_string();
|
||||
}
|
||||
// Windows 保留设备名(CON/NUL/COM1…)不能直接当文件名,撞名就补个尾巴。
|
||||
if matches!(suggested.to_ascii_uppercase().as_str(),
|
||||
"CON" | "PRN" | "AUX" | "NUL"
|
||||
| "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
|
||||
| "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9") {
|
||||
suggested.push_str("·页");
|
||||
}
|
||||
suggested.push('.');
|
||||
suggested.push_str(extension);
|
||||
let filter_label = if extension == "html" { "网页文件" } else { "Markdown" };
|
||||
let (sender, receiver) = std::sync::mpsc::channel::<Option<PathBuf>>();
|
||||
app.dialog()
|
||||
.file()
|
||||
.set_file_name(&suggested)
|
||||
.add_filter(filter_label, &[extension])
|
||||
.save_file(move |selection| {
|
||||
let picked = selection.as_ref().and_then(|path| path.as_path()).map(|path| path.to_path_buf());
|
||||
let _ = sender.send(picked);
|
||||
});
|
||||
let picked = tauri::async_runtime::spawn_blocking(move || receiver.recv().ok().flatten())
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))?;
|
||||
let Some(target) = picked else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = input.body.len() as u64;
|
||||
let path = target.display().to_string();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
fs::write(&target, input.body.as_bytes())
|
||||
.map_err(|error| format!("HOLOLAKE_EXPORT_WRITE_FAILED: {error}"))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))??;
|
||||
Ok(Some(ExportKnowledgeDocumentReceipt { path, bytes }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteKnowledgeDocumentInput {
|
||||
pub source: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnowledgeDeleteReceipt {
|
||||
pub removed: String,
|
||||
pub pages: usize,
|
||||
}
|
||||
|
||||
fn resolve_native_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
|
||||
let mut target = root.to_path_buf();
|
||||
for component in Path::new(relative).components() {
|
||||
match component {
|
||||
Component::Normal(part) => target.push(part),
|
||||
_ => return Err("HOLOLAKE_KNOWLEDGE_PATH_INVALID".into()),
|
||||
}
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
async fn confirm_destructive(app: &AppHandle, message: String) -> Result<bool, String> {
|
||||
let confirmer = app.clone();
|
||||
let confirmed = tauri::async_runtime::spawn_blocking(move || {
|
||||
confirmer
|
||||
.dialog()
|
||||
.message(message)
|
||||
.title("删除确认")
|
||||
.kind(MessageDialogKind::Warning)
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("确认删除".to_string(), "取消".to_string()))
|
||||
.blocking_show()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?;
|
||||
Ok(confirmed)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateKnowledgeDocumentInput {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_knowledge_document(
|
||||
app: AppHandle,
|
||||
input: CreateKnowledgeDocumentInput,
|
||||
) -> Result<KnowledgeDeleteReceipt, String> {
|
||||
// 新建空白页:标题撞名自动补序号,落进本机库并进 Git 托管。
|
||||
let roots = knowledge_roots(&app)?;
|
||||
let base = if input.title.trim().is_empty() { "未命名页面".to_string() } else { input.title.trim().to_string() };
|
||||
let docs = roots.0.join("docs");
|
||||
let mut name = base.clone();
|
||||
let mut counter = 2u32;
|
||||
while docs.join(format!("{name}.md")).exists() {
|
||||
name = format!("{base} {counter}");
|
||||
counter += 1;
|
||||
}
|
||||
let target = docs.join(format!("{name}.md"));
|
||||
let body = format!("# {name}\n\n");
|
||||
let receipt_name = name.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut file = private_file_options()
|
||||
.open(&target)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
|
||||
file.write_all(body.as_bytes())
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
|
||||
file.sync_all().map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
|
||||
git(&roots.0, &["add", "--", &format!("docs/{name}.md")], "ADD")?;
|
||||
git(&roots.0, &["commit", "-m", &format!("新建页面:{name}")], "COMMIT")?;
|
||||
Ok::<(), String>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_JOIN_FAILED: {error}"))??;
|
||||
Ok(KnowledgeDeleteReceipt { removed: format!("{receipt_name}.md"), pages: 1 })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_knowledge_document(
|
||||
app: AppHandle,
|
||||
input: DeleteKnowledgeDocumentInput,
|
||||
) -> Result<Option<KnowledgeDeleteReceipt>, String> {
|
||||
// 只删本机库(native);供体源只读,不碰。
|
||||
if input.source != "native" {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_DELETE_READ_ONLY_SOURCE".into());
|
||||
}
|
||||
let roots = knowledge_roots(&app)?;
|
||||
let target = resolve_native_path(&roots.0, &input.path)?;
|
||||
if !target.is_file() {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_NOT_FOUND".into());
|
||||
}
|
||||
let title = target
|
||||
.file_stem()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or("这一页")
|
||||
.to_string();
|
||||
if !confirm_destructive(&app, format!("把页面「{title}」移到回收站?随时可以找回。")).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
move_to_trash(&roots.0, &target)?;
|
||||
Ok(Some(KnowledgeDeleteReceipt { removed: input.path, pages: 1 }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteKnowledgeFolderInput {
|
||||
pub folder: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_knowledge_folder(
|
||||
app: AppHandle,
|
||||
input: DeleteKnowledgeFolderInput,
|
||||
) -> Result<Option<KnowledgeDeleteReceipt>, String> {
|
||||
// 删整个文件夹 = 删本机库里该相对目录及其全部页面。
|
||||
let roots = knowledge_roots(&app)?;
|
||||
let target = resolve_native_path(&roots.0, &input.folder)?;
|
||||
if target == roots.0 || !target.is_dir() {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_FOLDER_NOT_FOUND".into());
|
||||
}
|
||||
let name = target
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or(&input.folder)
|
||||
.to_string();
|
||||
let pages = fs::read_dir(&target)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_type().map(|t| t.is_file()).unwrap_or(false))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if !confirm_destructive(&app, format!("把文件夹「{name}」和里面全部 {pages} 个页面移到回收站?随时可以找回。")).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
move_to_trash(&roots.0, &target)?;
|
||||
Ok(Some(KnowledgeDeleteReceipt { removed: input.folder, pages }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> {
|
||||
// PDF 走系统打印通道:打印框里选"存储为 PDF"即得 PDF 文件。
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "HOLOLAKE_PRINT_WINDOW_NOT_FOUND".to_string())?;
|
||||
window.print().map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_and_import_knowledge_folder(
|
||||
app: AppHandle,
|
||||
|
|
@ -199,6 +427,22 @@ pub async fn select_and_import_knowledge_folder(
|
|||
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)
|
||||
|
|
@ -226,9 +470,38 @@ fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option<PathBuf>), String
|
|||
Ok((native, legacy))
|
||||
}
|
||||
|
||||
/// 写私有文件的跨平台选项:unix 上只许本人读写,Windows 上不做额外权限设置。
|
||||
fn private_file_options() -> OpenOptions {
|
||||
let mut options = OpenOptions::new();
|
||||
options.create_new(true).write(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
options
|
||||
}
|
||||
|
||||
/// 删除 = 搬到库内回收站(.trash),绝不真删;名字带时间戳防撞车。
|
||||
fn move_to_trash(root: &Path, target: &Path) -> Result<PathBuf, String> {
|
||||
let relative = target
|
||||
.strip_prefix(root)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let destination = root.join(".trash").join(format!("{}-{stamp}", relative.display()));
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
|
||||
}
|
||||
fs::rename(target, &destination)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(root.join("docs").join("导入"))
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?;
|
||||
if !root.join(".git").exists() {
|
||||
|
|
@ -401,10 +674,7 @@ fn save_document_at(
|
|||
}
|
||||
if previous != input.body.as_bytes() {
|
||||
let temporary = path.with_extension(format!("save-{}.tmp", Uuid::new_v4()));
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
let mut file = private_file_options()
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?;
|
||||
file.write_all(input.body.as_bytes())
|
||||
|
|
@ -687,6 +957,67 @@ fn walk_import(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// 外来页面进门即洗:只留文字,外壳全换成光湖原生 Markdown。
|
||||
/// 原有知识库的渲染层与页面跳转关系在此失效(冰朔 2026-08-15 谕)。
|
||||
fn convert_to_native_markdown(raw: &str) -> String {
|
||||
let text = raw.replace("\r\n", "\n");
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for line in text.split('\n') {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "<aside>" || trimmed == "</aside>" {
|
||||
continue;
|
||||
}
|
||||
if let Some(title) = trimmed.strip_prefix(":::toggle") {
|
||||
let title = title.trim();
|
||||
out.push_str("**▸ ");
|
||||
if title.is_empty() {
|
||||
out.push_str("详情");
|
||||
} else {
|
||||
out.push_str(title);
|
||||
}
|
||||
out.push_str("**\n");
|
||||
continue;
|
||||
}
|
||||
if trimmed == ":::" || trimmed.starts_with(":::toc") {
|
||||
continue;
|
||||
}
|
||||
let cleaned = line
|
||||
.replace("<br/>", "\n")
|
||||
.replace("<br />", "\n")
|
||||
.replace("<br>", "\n");
|
||||
out.push_str(&strip_notion_links(&cleaned));
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)。
|
||||
fn strip_notion_links(line: &str) -> String {
|
||||
let bytes: Vec<char> = line.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == '[' {
|
||||
if let Some(close) = bytes[i + 1..].iter().position(|c| *c == ']') {
|
||||
let text_end = i + 1 + close;
|
||||
if text_end + 1 < bytes.len() && bytes[text_end + 1] == '(' {
|
||||
if let Some(paren_end) = bytes[text_end + 2..].iter().position(|c| *c == ')') {
|
||||
let href: String = bytes[text_end + 2..text_end + 2 + paren_end].iter().collect();
|
||||
if href.contains("notion.so") || href.contains("notion.site") {
|
||||
out.extend(bytes[i + 1..text_end].iter());
|
||||
i = text_end + 2 + paren_end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn import_document(
|
||||
source: &Path,
|
||||
relative: &Path,
|
||||
|
|
@ -708,7 +1039,7 @@ fn import_document(
|
|||
.and_then(OsStr::to_str)
|
||||
.unwrap_or("未命名页面");
|
||||
let content = match extension.as_str() {
|
||||
"md" | "markdown" => raw,
|
||||
"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"),
|
||||
|
|
@ -730,10 +1061,7 @@ fn import_document(
|
|||
},
|
||||
));
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
let mut file = private_file_options()
|
||||
.open(&target)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
|
|
@ -757,10 +1085,7 @@ fn import_asset(source: &Path, target: &Path) -> Result<ImportDisposition, Strin
|
|||
}
|
||||
fs::create_dir_all(target.parent().unwrap_or_else(|| Path::new(".")))
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
let mut file = private_file_options()
|
||||
.open(target)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
|
|
|
|||
Loading…
Reference in a new issue