279 lines
8.7 KiB
Rust
279 lines
8.7 KiB
Rust
use crate::model::{
|
|
Channel, ExternalAiBridge, KnowledgeDocument, KnowledgeSummary, SourceKind, TimelineEvent,
|
|
};
|
|
use chrono::{DateTime, SecondsFormat, Utc};
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
fs,
|
|
path::{Component, Path, PathBuf},
|
|
process::Command,
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
pub fn now() -> String {
|
|
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
|
}
|
|
pub fn now_unix_ms() -> u128 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_millis())
|
|
.unwrap_or(0)
|
|
}
|
|
pub fn id(prefix: &str) -> String {
|
|
format!("{}-{}", prefix, UuidPart::new())
|
|
}
|
|
struct UuidPart(String);
|
|
impl UuidPart {
|
|
fn new() -> Self {
|
|
Self(uuid::Uuid::new_v4().simple().to_string()[..12].to_uppercase())
|
|
}
|
|
}
|
|
impl std::fmt::Display for UuidPart {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
pub fn sha(bytes: &[u8]) -> String {
|
|
format!("{:x}", Sha256::digest(bytes))
|
|
}
|
|
|
|
pub fn root(app: &AppHandle) -> Result<PathBuf, String> {
|
|
if let Some(value) = std::env::var_os("HOLOLAKE_RUNTIME_TEST_ROOT") {
|
|
let path = PathBuf::from(value);
|
|
if !path.is_absolute() || !path.starts_with(std::env::temp_dir()) {
|
|
return Err("HOLOLAKE_RUNTIME_TEST_ROOT_OUT_OF_SCOPE".into());
|
|
}
|
|
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
|
return Ok(path);
|
|
}
|
|
let p = app
|
|
.path()
|
|
.app_data_dir()
|
|
.map_err(|e| e.to_string())?
|
|
.join("runtime-v1");
|
|
fs::create_dir_all(&p).map_err(|e| e.to_string())?;
|
|
Ok(p)
|
|
}
|
|
pub fn read_json<T: serde::de::DeserializeOwned>(p: &Path) -> Result<T, String> {
|
|
serde_json::from_slice(&fs::read(p).map_err(|e| e.to_string())?).map_err(|e| e.to_string())
|
|
}
|
|
pub fn write_json<T: serde::Serialize + ?Sized>(p: &Path, v: &T) -> Result<(), String> {
|
|
if let Some(x) = p.parent() {
|
|
fs::create_dir_all(x).map_err(|e| e.to_string())?
|
|
}
|
|
let tmp = p.with_extension("tmp");
|
|
fs::write(
|
|
&tmp,
|
|
serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
fs::rename(tmp, p).map_err(|e| e.to_string())
|
|
}
|
|
pub fn channel(app: &AppHandle) -> Result<Option<Channel>, String> {
|
|
let p = root(app)?.join("channel.json");
|
|
if p.exists() {
|
|
read_json(&p).map(Some)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
pub fn save_channel(app: &AppHandle, c: &Channel) -> Result<(), String> {
|
|
write_json(&root(app)?.join("channel.json"), c)
|
|
}
|
|
pub fn event(
|
|
kind: SourceKind,
|
|
title: impl Into<String>,
|
|
content: impl Into<String>,
|
|
state: &str,
|
|
) -> TimelineEvent {
|
|
TimelineEvent {
|
|
event_id: id("HL-EVT"),
|
|
source_kind: kind,
|
|
actor_id: None,
|
|
title: title.into(),
|
|
content: content.into(),
|
|
occurred_at: now(),
|
|
state: state.into(),
|
|
}
|
|
}
|
|
pub fn actor_event(
|
|
kind: SourceKind,
|
|
actor_id: impl Into<String>,
|
|
title: impl Into<String>,
|
|
content: impl Into<String>,
|
|
state: &str,
|
|
) -> TimelineEvent {
|
|
let mut event = event(kind, title, content, state);
|
|
event.actor_id = Some(actor_id.into());
|
|
event
|
|
}
|
|
pub fn append_event(app: &AppHandle, e: &TimelineEvent) -> Result<(), String> {
|
|
let p = root(app)?.join("events.jsonl");
|
|
let mut s = if p.exists() {
|
|
fs::read_to_string(&p).map_err(|e| e.to_string())?
|
|
} else {
|
|
String::new()
|
|
};
|
|
s.push_str(&serde_json::to_string(e).map_err(|e| e.to_string())?);
|
|
s.push('\n');
|
|
fs::write(p, s).map_err(|e| e.to_string())
|
|
}
|
|
pub fn events(app: &AppHandle) -> Result<Vec<TimelineEvent>, String> {
|
|
let p = root(app)?.join("events.jsonl");
|
|
if !p.exists() {
|
|
return Ok(vec![]);
|
|
};
|
|
let mut v = fs::read_to_string(p)
|
|
.map_err(|e| e.to_string())?
|
|
.lines()
|
|
.filter_map(|l| serde_json::from_str(l).ok())
|
|
.collect::<Vec<_>>();
|
|
v.reverse();
|
|
Ok(v)
|
|
}
|
|
pub fn knowledge_root(app: &AppHandle) -> Result<PathBuf, String> {
|
|
let p = root(app)?.join("private-repository").join("knowledge");
|
|
fs::create_dir_all(p.join("docs")).map_err(|e| e.to_string())?;
|
|
ensure_git(&p)?;
|
|
Ok(p)
|
|
}
|
|
pub fn private_repo_root(app: &AppHandle) -> Result<PathBuf, String> {
|
|
let p = root(app)?.join("private-repository");
|
|
fs::create_dir_all(&p).map_err(|e| e.to_string())?;
|
|
ensure_git(&p)?;
|
|
Ok(p)
|
|
}
|
|
pub fn ensure_git(p: &Path) -> Result<(), String> {
|
|
if !p.join(".git").exists() {
|
|
run_git(p, &["init", "--initial-branch=main"])?;
|
|
run_git(p, &["config", "user.name", "HoloLake Local"])?;
|
|
run_git(p, &["config", "user.email", "local@hololake.invalid"])?;
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn git_commit(p: &Path, msg: &str) -> Result<Option<String>, String> {
|
|
run_git(p, &["add", "."])?;
|
|
let status = run_git(p, &["status", "--porcelain"])?;
|
|
if status.trim().is_empty() {
|
|
return Ok(None);
|
|
};
|
|
run_git(p, &["commit", "-m", msg])?;
|
|
Ok(Some(run_git(p, &["rev-parse", "HEAD"])?.trim().into()))
|
|
}
|
|
fn run_git(p: &Path, args: &[&str]) -> Result<String, String> {
|
|
let o = Command::new("git")
|
|
.current_dir(p)
|
|
.args(args)
|
|
.output()
|
|
.map_err(|e| format!("GIT_UNAVAILABLE: {e}"))?;
|
|
if !o.status.success() {
|
|
return Err(format!(
|
|
"GIT_FAILED: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
));
|
|
};
|
|
Ok(String::from_utf8_lossy(&o.stdout).into_owned())
|
|
}
|
|
pub fn safe_rel(value: &str) -> Result<PathBuf, String> {
|
|
let p = Path::new(value);
|
|
if p.is_absolute()
|
|
|| p.components().any(|c| {
|
|
matches!(
|
|
c,
|
|
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
|
)
|
|
})
|
|
{
|
|
return Err("PATH_OUT_OF_SCOPE".into());
|
|
}
|
|
Ok(p.to_path_buf())
|
|
}
|
|
pub fn list_docs(app: &AppHandle) -> Result<Vec<KnowledgeSummary>, String> {
|
|
let root = knowledge_root(app)?.join("docs");
|
|
let mut out = vec![];
|
|
collect(&root, &root, &mut out)?;
|
|
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
|
Ok(out)
|
|
}
|
|
fn collect(root: &Path, p: &Path, out: &mut Vec<KnowledgeSummary>) -> Result<(), String> {
|
|
for e in fs::read_dir(p).map_err(|e| e.to_string())? {
|
|
let e = e.map_err(|e| e.to_string())?;
|
|
let path = e.path();
|
|
if path.is_dir() {
|
|
collect(root, &path, out)?
|
|
} else if path.extension().and_then(|x| x.to_str()) == Some("md") {
|
|
let b = fs::read(&path).map_err(|e| e.to_string())?;
|
|
let rel = path
|
|
.strip_prefix(root)
|
|
.map_err(|e| e.to_string())?
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
let body = String::from_utf8_lossy(&b);
|
|
let title = body
|
|
.lines()
|
|
.find_map(|x| x.strip_prefix("# "))
|
|
.unwrap_or_else(|| {
|
|
path.file_stem()
|
|
.and_then(|x| x.to_str())
|
|
.unwrap_or("未命名")
|
|
})
|
|
.into();
|
|
let updated = e
|
|
.metadata()
|
|
.map_err(|e| e.to_string())?
|
|
.modified()
|
|
.ok()
|
|
.map(|t| DateTime::<Utc>::from(t).to_rfc3339_opts(SecondsFormat::Millis, true))
|
|
.unwrap_or_else(|| "1970-01-01T00:00:00.000Z".into());
|
|
out.push(KnowledgeSummary {
|
|
path: rel,
|
|
title,
|
|
updated_at: updated,
|
|
sha256: sha(&b),
|
|
})
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn read_doc(app: &AppHandle, path: &str) -> Result<KnowledgeDocument, String> {
|
|
let rel = safe_rel(path)?;
|
|
let p = knowledge_root(app)?.join("docs").join(rel);
|
|
let b = fs::read(&p).map_err(|e| e.to_string())?;
|
|
let body = String::from_utf8(b.clone()).map_err(|_| "DOCUMENT_NOT_UTF8".to_string())?;
|
|
let title = body
|
|
.lines()
|
|
.find_map(|x| x.strip_prefix("# "))
|
|
.unwrap_or_else(|| p.file_stem().and_then(|x| x.to_str()).unwrap_or("未命名"))
|
|
.into();
|
|
Ok(KnowledgeDocument {
|
|
path: path.into(),
|
|
title,
|
|
body,
|
|
updated_at: now(),
|
|
sha256: sha(&b),
|
|
})
|
|
}
|
|
pub fn bridges(app: &AppHandle) -> Result<Vec<ExternalAiBridge>, String> {
|
|
let p = root(app)?.join("external-ai-bridges.json");
|
|
if p.exists() {
|
|
read_json(&p)
|
|
} else {
|
|
Ok(vec![])
|
|
}
|
|
}
|
|
pub fn save_bridges(app: &AppHandle, v: &[ExternalAiBridge]) -> Result<(), String> {
|
|
write_json(&root(app)?.join("external-ai-bridges.json"), &v)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn runtime_timestamps_are_rfc3339_utc() {
|
|
let value = now();
|
|
assert!(value.ends_with('Z'));
|
|
assert!(DateTime::parse_from_rfc3339(&value).is_ok());
|
|
}
|
|
}
|