feat: 零点原核频道v1+登录双门地基+五湖开场——zero_point裁决链/灯塔查号/code_repo_login/通知卡重设计/浅色主题联动(冰朔20260815夜谕)

This commit is contained in:
冰朔 2026-08-16 00:02:05 +08:00
commit 07fd4c85ef
16 changed files with 3082 additions and 88 deletions

View file

@ -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)