mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
v1.0.0 - Initial release
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::types::AiStreamEvent;
|
||||
|
||||
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||
|
||||
pub fn ai_request(
|
||||
app: AppHandle,
|
||||
provider: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
system_prompt: String,
|
||||
user_message: String,
|
||||
request_id: String,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let result = match provider.as_str() {
|
||||
"openai" => {
|
||||
stream_openai(
|
||||
&app,
|
||||
&api_key,
|
||||
&model,
|
||||
&system_prompt,
|
||||
&user_message,
|
||||
&request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
stream_anthropic(
|
||||
&app,
|
||||
&api_key,
|
||||
&model,
|
||||
&system_prompt,
|
||||
&user_message,
|
||||
&request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "error".to_string(),
|
||||
text: None,
|
||||
error: Some(e),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async fn stream_anthropic(
|
||||
app: &AppHandle,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
_request_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let client = Client::new();
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"stream": true,
|
||||
"system": system_prompt,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": user_message
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(ANTHROPIC_API_URL)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
return Err(format!("API error {}: {}", status, body_text));
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
use futures::StreamExt;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// Process complete SSE events from buffer
|
||||
while let Some(event_end) = buffer.find("\n\n") {
|
||||
let event_str = buffer[..event_end].to_string();
|
||||
buffer = buffer[event_end + 2..].to_string();
|
||||
|
||||
for line in event_str.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data == "[DONE]" {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
let event_type = parsed["type"].as_str().unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"content_block_delta" => {
|
||||
if let Some(text) = parsed["delta"]["text"].as_str() {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "text".to_string(),
|
||||
text: Some(text.to_string()),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
"message_stop" => {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
"error" => {
|
||||
let msg = parsed["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown API error");
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "error".to_string(),
|
||||
text: None,
|
||||
error: Some(msg.to_string()),
|
||||
},
|
||||
);
|
||||
return Err(msg.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended — send done
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stream_openai(
|
||||
app: &AppHandle,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
_request_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let client = Client::new();
|
||||
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 {
|
||||
"max_completion_tokens"
|
||||
} else {
|
||||
"max_tokens"
|
||||
};
|
||||
|
||||
let mut body = json!({
|
||||
"model": model,
|
||||
"stream": true,
|
||||
token_key: 4096,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": user_message
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// GPT-5 models don't support temperature
|
||||
if !is_gpt5 {
|
||||
body["temperature"] = json!(0.7);
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(OPENAI_API_URL)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
return Err(format!("API error {}: {}", status, body_text));
|
||||
}
|
||||
|
||||
use futures::StreamExt;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
while let Some(event_end) = buffer.find("\n\n") {
|
||||
let event_str = buffer[..event_end].to_string();
|
||||
buffer = buffer[event_end + 2..].to_string();
|
||||
|
||||
for line in event_str.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data == "[DONE]" {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
// OpenAI streaming: choices[0].delta.content
|
||||
if let Some(content) = parsed["choices"][0]["delta"]["content"].as_str() {
|
||||
if !content.is_empty() {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "text".to_string(),
|
||||
text: Some(content.to_string()),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check finish_reason
|
||||
if let Some(reason) = parsed["choices"][0]["finish_reason"].as_str() {
|
||||
if reason == "stop" || reason == "length" {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for error in stream
|
||||
if let Some(err) = parsed["error"]["message"].as_str() {
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "error".to_string(),
|
||||
text: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
);
|
||||
return Err(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
event_type: "done".to_string(),
|
||||
text: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn test_connection(provider: &str, api_key: &str, model: &str) -> Result<String, String> {
|
||||
match provider {
|
||||
"openai" => test_openai(api_key, model).await,
|
||||
_ => test_anthropic(api_key, model).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 10,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(ANTHROPIC_API_URL)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok("Connection successful".to_string())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
Err(format!("API error {}: {}", status, body_text))
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_openai(api_key: &str, model: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 {
|
||||
"max_completion_tokens"
|
||||
} else {
|
||||
"max_tokens"
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
token_key: 10,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(OPENAI_API_URL)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok("Connection successful".to_string())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
Err(format!("API error {}: {}", status, body_text))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use walkdir::WalkDir;
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::ZipArchive;
|
||||
|
||||
use crate::types::BackupEntry;
|
||||
|
||||
/// Returns the default backup directory (~/.config/helixnotes/backups/)
|
||||
pub fn default_backup_dir() -> Result<PathBuf, String> {
|
||||
let config_dir = dirs::config_dir().ok_or("Cannot find config directory")?;
|
||||
let dir = config_dir.join("helixnotes").join("backups");
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Returns the effective backup directory (custom or default)
|
||||
pub fn get_backup_dir(custom: &Option<String>) -> Result<PathBuf, String> {
|
||||
match custom {
|
||||
Some(p) if !p.is_empty() => {
|
||||
let dir = PathBuf::from(p);
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
_ => default_backup_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a backup zip of the vault
|
||||
pub fn create_backup(
|
||||
vault_path: &str,
|
||||
backup_dir: &Path,
|
||||
include_attachments: bool,
|
||||
) -> Result<BackupEntry, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.is_dir() {
|
||||
return Err("Vault directory does not exist".to_string());
|
||||
}
|
||||
|
||||
if !backup_dir.exists() {
|
||||
fs::create_dir_all(backup_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let timestamp = now.format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||
let filename = format!("helixnotes-backup-{}.zip", timestamp);
|
||||
let backup_path = backup_dir.join(&filename);
|
||||
|
||||
let file = fs::File::create(&backup_path)
|
||||
.map_err(|e| format!("Failed to create backup file: {}", e))?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.compression_level(Some(6));
|
||||
|
||||
let helixnotes_dir = vault.join(".helixnotes");
|
||||
let attachments_dir = helixnotes_dir.join("attachments");
|
||||
|
||||
for entry in WalkDir::new(vault).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
|
||||
// Handle .helixnotes/ directory
|
||||
if path.starts_with(&helixnotes_dir) {
|
||||
if include_attachments {
|
||||
// Include attachments/ but skip everything else
|
||||
if !path.starts_with(&attachments_dir) && path != helixnotes_dir {
|
||||
continue;
|
||||
}
|
||||
if path == helixnotes_dir {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Skip .helixnotes entirely
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip hidden files/directories (except .helixnotes which we handle above)
|
||||
if path != vault {
|
||||
if let Some(name) = path.file_name() {
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with('.') && name_str != ".helixnotes" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let relative = path.strip_prefix(vault).map_err(|e| e.to_string())?;
|
||||
|
||||
if path.is_dir() {
|
||||
let dir_name = format!("{}/", relative.to_string_lossy());
|
||||
if dir_name != "/" {
|
||||
zip.add_directory(&dir_name, options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
} else {
|
||||
let name = relative.to_string_lossy().to_string();
|
||||
zip.start_file(&name, options).map_err(|e| e.to_string())?;
|
||||
let mut f = fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut buffer = Vec::new();
|
||||
f.read_to_end(&mut buffer).map_err(|e| e.to_string())?;
|
||||
zip.write_all(&buffer).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
|
||||
let meta = fs::metadata(&backup_path).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(BackupEntry {
|
||||
filename: filename.clone(),
|
||||
path: backup_path.to_string_lossy().to_string(),
|
||||
size: meta.len(),
|
||||
created: now.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
/// List all backups in the backup directory
|
||||
pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
||||
if !backup_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut entries: Vec<BackupEntry> = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(backup_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map_or(false, |ext| ext == "zip") {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let meta = fs::metadata(&path).map_err(|e| e.to_string())?;
|
||||
|
||||
// Parse timestamp from filename: helixnotes-backup-YYYY-MM-DDTHH-MM-SS.zip
|
||||
let created = if let Some(ts) = filename
|
||||
.strip_prefix("helixnotes-backup-")
|
||||
.and_then(|s| s.strip_suffix(".zip"))
|
||||
{
|
||||
// ts = "2026-02-08T18-09-15" → "2026-02-08T18:09:15Z"
|
||||
if let Some(t_pos) = ts.find('T') {
|
||||
let date_part = &ts[..t_pos];
|
||||
let time_part = &ts[t_pos + 1..];
|
||||
let time_colons = time_part.replace('-', ":");
|
||||
format!("{}T{}Z", date_part, time_colons)
|
||||
} else {
|
||||
meta.modified()
|
||||
.map(|t| {
|
||||
let dt: chrono::DateTime<Utc> = t.into();
|
||||
dt.to_rfc3339()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
meta.modified()
|
||||
.map(|t| {
|
||||
let dt: chrono::DateTime<Utc> = t.into();
|
||||
dt.to_rfc3339()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
entries.push(BackupEntry {
|
||||
filename,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
size: meta.len(),
|
||||
created,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
entries.sort_by(|a, b| b.created.cmp(&a.created));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Restore a backup by extracting the zip over the vault directory
|
||||
pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let backup = Path::new(backup_path);
|
||||
|
||||
if !backup.exists() {
|
||||
return Err("Backup file does not exist".to_string());
|
||||
}
|
||||
|
||||
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
||||
let mut archive =
|
||||
ZipArchive::new(file).map_err(|e| format!("Invalid backup archive: {}", e))?;
|
||||
|
||||
// Clear existing notes (but keep .helixnotes/ internals except attachments)
|
||||
for entry in fs::read_dir(vault).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Keep hidden dirs (.helixnotes) — we'll handle attachments separately
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
fs::remove_file(&path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing attachments so backup's attachments replace them
|
||||
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
||||
if attachments_dir.exists() {
|
||||
fs::remove_dir_all(&attachments_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Extract archive
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
let outpath = vault.join(file.mangled_name());
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(parent) = outpath.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a single backup file
|
||||
pub fn delete_backup(backup_path: &str) -> Result<(), String> {
|
||||
let path = Path::new(backup_path);
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove old backups keeping only the newest `max_count`
|
||||
pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), String> {
|
||||
let mut backups = list_backups(backup_dir)?;
|
||||
|
||||
// backups are already sorted newest-first
|
||||
if backups.len() as u32 > max_count {
|
||||
let to_remove = backups.split_off(max_count as usize);
|
||||
for entry in to_remove {
|
||||
delete_backup(&entry.path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::types::VersionEntry;
|
||||
|
||||
/// Directory: .helixnotes/history/<note-id>/
|
||||
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
|
||||
Path::new(vault_path)
|
||||
.join(".helixnotes")
|
||||
.join("history")
|
||||
.join(note_id)
|
||||
}
|
||||
|
||||
/// Save a version snapshot if enough time has passed since the last one.
|
||||
/// Minimum interval: 5 minutes between snapshots.
|
||||
pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||
let dir = history_dir(vault_path, note_id);
|
||||
|
||||
// Check if we should create a snapshot (5 min cooldown)
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
let mut files: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
|
||||
.filter(|n| n.ends_with(".md"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
if let Some(last) = files.last() {
|
||||
if let Some(ts) = last.strip_suffix(".md") {
|
||||
// Parse timestamp: 2026-02-08T18-30-00.md
|
||||
if let Some(t_pos) = ts.find('T') {
|
||||
let date_part = &ts[..t_pos];
|
||||
let time_part = &ts[t_pos + 1..];
|
||||
let time_colons = time_part.replace('-', ":");
|
||||
let iso = format!("{}T{}Z", date_part, time_colons);
|
||||
if let Ok(last_time) = iso.parse::<DateTime<Utc>>() {
|
||||
let elapsed = Utc::now() - last_time;
|
||||
if elapsed.num_minutes() < 5 {
|
||||
return; // Too soon, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create snapshot
|
||||
if let Err(e) = fs::create_dir_all(&dir) {
|
||||
eprintln!("Failed to create history dir: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||
let filename = format!("{}.md", timestamp);
|
||||
let path = dir.join(&filename);
|
||||
|
||||
if let Err(e) = fs::write(&path, raw_content) {
|
||||
eprintln!("Failed to write version snapshot: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prune old versions
|
||||
if max_versions > 0 {
|
||||
let _ = prune_versions(&dir, max_versions);
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-create a version snapshot, bypassing the cooldown.
|
||||
pub fn force_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_versions: u32) {
|
||||
let dir = history_dir(vault_path, note_id);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(&dir) {
|
||||
eprintln!("Failed to create history dir: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||
let filename = format!("{}.md", timestamp);
|
||||
let path = dir.join(&filename);
|
||||
|
||||
if let Err(e) = fs::write(&path, raw_content) {
|
||||
eprintln!("Failed to write version snapshot: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if max_versions > 0 {
|
||||
let _ = prune_versions(&dir, max_versions);
|
||||
}
|
||||
}
|
||||
|
||||
/// List all version snapshots for a note, newest first.
|
||||
pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry>, String> {
|
||||
let dir = history_dir(vault_path, note_id);
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut entries: Vec<VersionEntry> = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |ext| ext == "md") {
|
||||
let filename = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let meta = fs::metadata(&path).map_err(|e| e.to_string())?;
|
||||
|
||||
// Parse timestamp from filename
|
||||
let timestamp = if let Some(t_pos) = filename.find('T') {
|
||||
let date_part = &filename[..t_pos];
|
||||
let time_part = &filename[t_pos + 1..];
|
||||
let time_colons = time_part.replace('-', ":");
|
||||
format!("{}T{}Z", date_part, time_colons)
|
||||
} else {
|
||||
filename.clone()
|
||||
};
|
||||
|
||||
entries.push(VersionEntry {
|
||||
timestamp,
|
||||
size: meta.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Newest first
|
||||
entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get the raw content of a specific version.
|
||||
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
||||
// Convert ISO timestamp back to filename: 2026-02-08T18:30:00Z → 2026-02-08T18-30-00.md
|
||||
let filename = if let Some(t_pos) = timestamp.find('T') {
|
||||
let date_part = ×tamp[..t_pos];
|
||||
let time_part = timestamp[t_pos + 1..].trim_end_matches('Z');
|
||||
let time_dashes = time_part.replace(':', "-");
|
||||
format!("{}.md", format!("{}T{}", date_part, time_dashes))
|
||||
} else {
|
||||
format!("{}.md", timestamp)
|
||||
};
|
||||
|
||||
let path = history_dir(vault_path, note_id).join(&filename);
|
||||
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
||||
}
|
||||
|
||||
/// Remove old versions keeping only the newest `max` entries.
|
||||
fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
||||
.collect();
|
||||
|
||||
// Sort by name (timestamps sort lexicographically) — newest last
|
||||
files.sort();
|
||||
|
||||
if files.len() as u32 > max {
|
||||
let to_remove = files.len() as u32 - max;
|
||||
for path in files.iter().take(to_remove as usize) {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
mod ai;
|
||||
mod backup;
|
||||
mod commands;
|
||||
mod history;
|
||||
mod search;
|
||||
mod state;
|
||||
mod types;
|
||||
mod vault;
|
||||
|
||||
use state::AppState;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::TrayIconBuilder,
|
||||
Manager,
|
||||
};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let config = commands::load_app_config();
|
||||
let show_tray = config.show_tray_icon;
|
||||
let app_state = AppState::new(config);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(move |app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
|
||||
if show_tray {
|
||||
setup_tray(app)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::open_vault,
|
||||
commands::get_app_config,
|
||||
commands::set_theme,
|
||||
commands::set_accent_color,
|
||||
commands::set_font_size,
|
||||
commands::set_font_family,
|
||||
commands::get_notebooks,
|
||||
commands::create_notebook,
|
||||
commands::rename_notebook,
|
||||
commands::delete_notebook,
|
||||
commands::get_notes,
|
||||
commands::read_note,
|
||||
commands::save_note,
|
||||
commands::create_note,
|
||||
commands::rename_note,
|
||||
commands::delete_note,
|
||||
commands::move_note,
|
||||
commands::get_all_tags,
|
||||
commands::get_all_note_titles,
|
||||
commands::search_notes,
|
||||
commands::reindex,
|
||||
commands::get_trash,
|
||||
commands::restore_note,
|
||||
commands::permanent_delete,
|
||||
commands::empty_trash,
|
||||
commands::load_vault_state,
|
||||
commands::save_vault_state,
|
||||
commands::save_image,
|
||||
commands::save_attachment,
|
||||
commands::get_notebook_icons,
|
||||
commands::set_notebook_icon,
|
||||
commands::set_general_settings,
|
||||
commands::get_quick_access,
|
||||
commands::add_quick_access,
|
||||
commands::remove_quick_access,
|
||||
commands::get_vault_stats,
|
||||
commands::import_obsidian,
|
||||
commands::open_file,
|
||||
commands::copy_file_to,
|
||||
commands::create_backup,
|
||||
commands::list_backups,
|
||||
commands::restore_backup,
|
||||
commands::delete_backup,
|
||||
commands::set_backup_settings,
|
||||
commands::get_note_versions,
|
||||
commands::get_note_version_content,
|
||||
commands::create_version,
|
||||
commands::set_ai_settings,
|
||||
commands::test_ai_connection,
|
||||
commands::ai_ask,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||
let menu = MenuBuilder::new(app).items(&[&show, &quit]).build()?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(
|
||||
Image::from_path("icons/32x32.png")
|
||||
.unwrap_or_else(|_| app.default_window_icon().cloned().unwrap()),
|
||||
)
|
||||
.menu(&menu)
|
||||
.tooltip("HelixNotes")
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click { .. } = event {
|
||||
if let Some(window) = tray.app_handle().get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use crate::types::SearchResult;
|
||||
use crate::vault::frontmatter;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tantivy::collector::TopDocs;
|
||||
use tantivy::directory::MmapDirectory;
|
||||
use tantivy::query::QueryParser;
|
||||
use tantivy::schema::*;
|
||||
use tantivy::{Index, IndexWriter, TantivyDocument};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub struct SearchIndex {
|
||||
index: Index,
|
||||
writer: Mutex<Option<IndexWriter>>,
|
||||
#[allow(dead_code)]
|
||||
schema: Schema,
|
||||
path_field: Field,
|
||||
title_field: Field,
|
||||
body_field: Field,
|
||||
tags_field: Field,
|
||||
}
|
||||
|
||||
impl SearchIndex {
|
||||
pub fn new(vault_path: &str) -> Result<Self, String> {
|
||||
let index_dir = helixnotes_dir(vault_path).join("search_index");
|
||||
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut schema_builder = Schema::builder();
|
||||
let path_field = schema_builder.add_text_field("path", STRING | STORED);
|
||||
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
|
||||
let body_field = schema_builder.add_text_field("body", TEXT);
|
||||
let tags_field = schema_builder.add_text_field("tags", TEXT | STORED);
|
||||
let schema = schema_builder.build();
|
||||
|
||||
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
|
||||
let index = Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
let writer = index
|
||||
.writer(50_000_000)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
index,
|
||||
writer: Mutex::new(Some(writer)),
|
||||
schema,
|
||||
path_field,
|
||||
title_field,
|
||||
body_field,
|
||||
tags_field,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rebuild(&self, vault_path: &str) -> Result<(), String> {
|
||||
let mut writer_guard = self.writer.lock().map_err(|e| e.to_string())?;
|
||||
let writer = writer_guard.as_mut().ok_or("Writer not available")?;
|
||||
|
||||
writer.delete_all_documents().map_err(|e| e.to_string())?;
|
||||
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
|
||||
for entry in WalkDir::new(vault_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let p = e.path();
|
||||
p.is_file()
|
||||
&& p.extension().and_then(|x| x.to_str()) == Some("md")
|
||||
&& !p.starts_with(&hn_dir)
|
||||
&& !p
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
if let Ok(raw) = fs::read_to_string(entry.path()) {
|
||||
let filename = entry.file_name().to_string_lossy().to_string();
|
||||
let (meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
let path_str = entry.path().to_string_lossy().to_string();
|
||||
|
||||
let mut doc = TantivyDocument::new();
|
||||
doc.add_text(self.path_field, &path_str);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
doc.add_text(self.body_field, &content);
|
||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
}
|
||||
}
|
||||
|
||||
writer.commit().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn index_note(&self, path: &str) -> Result<(), String> {
|
||||
let p = Path::new(path);
|
||||
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||
let filename = p
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
let mut writer_guard = self.writer.lock().map_err(|e| e.to_string())?;
|
||||
let writer = writer_guard.as_mut().ok_or("Writer not available")?;
|
||||
|
||||
// Delete old entry
|
||||
let term = tantivy::Term::from_field_text(self.path_field, path);
|
||||
writer.delete_term(term);
|
||||
|
||||
// Add updated
|
||||
let mut doc = TantivyDocument::new();
|
||||
doc.add_text(self.path_field, path);
|
||||
doc.add_text(self.title_field, &meta.title);
|
||||
doc.add_text(self.body_field, &content);
|
||||
doc.add_text(self.tags_field, &meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
|
||||
writer.commit().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_note(&self, path: &str) -> Result<(), String> {
|
||||
let mut writer_guard = self.writer.lock().map_err(|e| e.to_string())?;
|
||||
let writer = writer_guard.as_mut().ok_or("Writer not available")?;
|
||||
let term = tantivy::Term::from_field_text(self.path_field, path);
|
||||
writer.delete_term(term);
|
||||
writer.commit().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn search(&self, query_str: &str, limit: usize) -> Result<Vec<SearchResult>, String> {
|
||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let query_parser =
|
||||
QueryParser::for_index(&self.index, vec![self.title_field, self.body_field, self.tags_field]);
|
||||
|
||||
let query = query_parser
|
||||
.parse_query(query_str)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let top_docs = searcher
|
||||
.search(&query, &TopDocs::with_limit(limit))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (score, doc_address) in top_docs {
|
||||
let doc: TantivyDocument = searcher.doc(doc_address).map_err(|e| e.to_string())?;
|
||||
|
||||
let path = doc
|
||||
.get_first(self.path_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let title = doc
|
||||
.get_first(self.title_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
results.push(SearchResult {
|
||||
path,
|
||||
title,
|
||||
snippet: String::new(),
|
||||
score,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use crate::search::SearchIndex;
|
||||
use crate::types::AppConfig;
|
||||
use notify::RecommendedWatcher;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<SearchIndex>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub importing: AtomicBool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
Self {
|
||||
config: Mutex::new(config),
|
||||
search_index: Mutex::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
importing: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NoteMeta {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub tags: Vec<String>,
|
||||
pub pinned: bool,
|
||||
pub created: DateTime<Utc>,
|
||||
pub modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NoteEntry {
|
||||
pub path: String,
|
||||
pub relative_path: String,
|
||||
pub meta: NoteMeta,
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotebookEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub relative_path: String,
|
||||
pub children: Vec<NotebookEntry>,
|
||||
pub note_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NoteContent {
|
||||
pub path: String,
|
||||
pub meta: NoteMeta,
|
||||
pub content: String,
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VaultConfig {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub vaults: Vec<VaultConfig>,
|
||||
pub active_vault: Option<String>,
|
||||
pub theme: String,
|
||||
#[serde(default)]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub font_size: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub font_family: Option<String>,
|
||||
#[serde(default)]
|
||||
pub compact_notes: bool,
|
||||
#[serde(default)]
|
||||
pub time_format: String,
|
||||
#[serde(default)]
|
||||
pub gpu_acceleration: bool,
|
||||
#[serde(default)]
|
||||
pub autostart: bool,
|
||||
#[serde(default)]
|
||||
pub pdf_preview: bool,
|
||||
#[serde(default = "default_pdf_height")]
|
||||
pub pdf_height: u32,
|
||||
#[serde(default = "default_title_mode")]
|
||||
pub title_mode: String,
|
||||
#[serde(default)]
|
||||
pub hide_title_in_body: bool,
|
||||
#[serde(default)]
|
||||
pub backup_enabled: bool,
|
||||
#[serde(default = "default_backup_frequency")]
|
||||
pub backup_frequency: String,
|
||||
#[serde(default = "default_backup_max")]
|
||||
pub backup_max_count: u32,
|
||||
#[serde(default)]
|
||||
pub backup_location: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_backup_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub backup_include_attachments: bool,
|
||||
#[serde(default = "default_max_versions")]
|
||||
pub max_versions_per_note: u32,
|
||||
#[serde(default)]
|
||||
pub ai_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ai_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_api_key: Option<String>,
|
||||
#[serde(default = "default_ai_model")]
|
||||
pub ai_model: String,
|
||||
#[serde(default)]
|
||||
pub ai_writing_style: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_view_mode: bool,
|
||||
#[serde(default)]
|
||||
pub show_tray_icon: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_wiki_links: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_pdf_height() -> u32 {
|
||||
600
|
||||
}
|
||||
|
||||
fn default_backup_frequency() -> String {
|
||||
"24h".to_string()
|
||||
}
|
||||
|
||||
fn default_backup_max() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_title_mode() -> String {
|
||||
"input".to_string()
|
||||
}
|
||||
|
||||
fn default_max_versions() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_ai_model() -> String {
|
||||
"claude-sonnet-4-5-20250929".to_string()
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vaults: Vec::new(),
|
||||
active_vault: None,
|
||||
theme: "system".to_string(),
|
||||
accent_color: None,
|
||||
font_size: None,
|
||||
font_family: None,
|
||||
compact_notes: false,
|
||||
time_format: "relative".to_string(),
|
||||
gpu_acceleration: true,
|
||||
autostart: false,
|
||||
pdf_preview: false,
|
||||
pdf_height: 600,
|
||||
title_mode: "input".to_string(),
|
||||
hide_title_in_body: false,
|
||||
backup_enabled: false,
|
||||
backup_frequency: "24h".to_string(),
|
||||
backup_max_count: 10,
|
||||
backup_location: None,
|
||||
last_backup_time: None,
|
||||
backup_include_attachments: false,
|
||||
max_versions_per_note: 20,
|
||||
ai_provider: None,
|
||||
ai_api_key: None,
|
||||
openai_api_key: None,
|
||||
ai_model: "claude-sonnet-4-5-20250929".to_string(),
|
||||
ai_writing_style: None,
|
||||
default_view_mode: false,
|
||||
show_tray_icon: false,
|
||||
enable_wiki_links: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VaultState {
|
||||
pub last_open_note: Option<String>,
|
||||
pub sidebar_width: f64,
|
||||
pub notelist_width: f64,
|
||||
pub sidebar_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub collapsed_notebooks: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for VaultState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_open_note: None,
|
||||
sidebar_width: 220.0,
|
||||
notelist_width: 280.0,
|
||||
sidebar_collapsed: false,
|
||||
collapsed_notebooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileEvent {
|
||||
pub event_type: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportResult {
|
||||
pub files_converted: u64,
|
||||
pub links_converted: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VaultStats {
|
||||
pub total_notes: u64,
|
||||
pub total_attachments: u64,
|
||||
pub notes_size: u64,
|
||||
pub attachments_size: u64,
|
||||
pub total_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupEntry {
|
||||
pub filename: String,
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VersionEntry {
|
||||
pub timestamp: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiStreamEvent {
|
||||
pub event_type: String, // "text", "done", "error"
|
||||
pub text: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NoteTitleEntry {
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use crate::types::NoteMeta;
|
||||
use chrono::Utc;
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
struct RawFrontmatter {
|
||||
id: Option<String>,
|
||||
title: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
pinned: Option<bool>,
|
||||
created: Option<String>,
|
||||
modified: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
|
||||
let matter = Matter::<YAML>::new();
|
||||
let result = matter.parse(raw);
|
||||
|
||||
let fm: RawFrontmatter = result
|
||||
.data
|
||||
.and_then(|d| d.deserialize().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = fm.title.unwrap_or_else(|| filename_to_title(filename));
|
||||
|
||||
let created = fm
|
||||
.created
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let modified = fm
|
||||
.modified
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let id = fm.id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
let meta = NoteMeta {
|
||||
id,
|
||||
title,
|
||||
tags: fm.tags.unwrap_or_default(),
|
||||
pinned: fm.pinned.unwrap_or(false),
|
||||
created,
|
||||
modified,
|
||||
};
|
||||
|
||||
let content = result.content;
|
||||
(meta, content)
|
||||
}
|
||||
|
||||
pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
||||
let tags_str = if meta.tags.is_empty() {
|
||||
"[]".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"[{}]",
|
||||
meta.tags
|
||||
.iter()
|
||||
.map(|t| format!("{}", t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
|
||||
format!(
|
||||
"---\nid: \"{}\"\ntitle: \"{}\"\ntags: {}\npinned: {}\ncreated: {}\nmodified: {}\n---\n",
|
||||
meta.id,
|
||||
meta.title.replace('"', "\\\""),
|
||||
tags_str,
|
||||
meta.pinned,
|
||||
meta.created.to_rfc3339(),
|
||||
meta.modified.to_rfc3339(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String {
|
||||
let fm = serialize_frontmatter(meta);
|
||||
format!("{}{}", fm, body)
|
||||
}
|
||||
|
||||
fn filename_to_title(filename: &str) -> String {
|
||||
let stem = filename.trim_end_matches(".md");
|
||||
// Only replace dashes/underscores acting as word separators (between non-space chars).
|
||||
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
|
||||
let mut result = String::with_capacity(stem.len());
|
||||
let chars: Vec<char> = stem.chars().collect();
|
||||
for (i, &ch) in chars.iter().enumerate() {
|
||||
if (ch == '-' || ch == '_')
|
||||
&& i > 0
|
||||
&& i < chars.len() - 1
|
||||
&& chars[i - 1] != ' '
|
||||
&& chars[i + 1] != ' '
|
||||
{
|
||||
result.push(' ');
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Quick title extraction from raw note content (frontmatter only, no full parse)
|
||||
pub fn extract_title(raw: &str) -> Option<String> {
|
||||
let matter = Matter::<YAML>::new();
|
||||
let result = matter.parse(raw);
|
||||
let fm: RawFrontmatter = result
|
||||
.data
|
||||
.and_then(|d| d.deserialize().ok())
|
||||
.unwrap_or_default();
|
||||
fm.title
|
||||
}
|
||||
|
||||
pub fn extract_preview(content: &str, max_len: usize) -> String {
|
||||
// Filter out headings and empty lines BEFORE stripping markdown,
|
||||
// since strip_html_and_markdown collapses all whitespace into one line.
|
||||
let filtered: String = content
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let trimmed = l.trim();
|
||||
!trimmed.is_empty()
|
||||
&& !trimmed.starts_with('#')
|
||||
&& !trimmed.starts_with("---")
|
||||
&& !trimmed.starts_with("```")
|
||||
})
|
||||
.take(5)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let text = strip_html_and_markdown(&filtered);
|
||||
let trimmed = text.trim().to_string();
|
||||
|
||||
if trimmed.len() > max_len {
|
||||
let mut end = max_len;
|
||||
while end > 0 && !trimmed.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}...", &trimmed[..end])
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_html_and_markdown(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
let mut in_tag = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if in_tag {
|
||||
if ch == '>' {
|
||||
in_tag = false;
|
||||
// Add a space after closing tags to separate words
|
||||
result.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '<' {
|
||||
in_tag = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip markdown image syntax: 
|
||||
if ch == '!' && chars.peek() == Some(&'[') {
|
||||
// Consume  entirely
|
||||
chars.next(); // skip '['
|
||||
let mut depth = 1;
|
||||
// Skip alt text
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '[' {
|
||||
depth += 1;
|
||||
}
|
||||
if c == ']' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip (url) if present
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
}
|
||||
if c == ')' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown links: [text](url) — keep the text, skip the URL
|
||||
if ch == '[' {
|
||||
let mut link_text = String::new();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '[' {
|
||||
depth += 1;
|
||||
}
|
||||
if c == ']' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
link_text.push(c);
|
||||
}
|
||||
// Skip (url) if present
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
}
|
||||
if c == ')' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push_str(&link_text);
|
||||
} else {
|
||||
// Not a link, just brackets
|
||||
result.push('[');
|
||||
result.push_str(&link_text);
|
||||
result.push(']');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip bold/italic markers
|
||||
if ch == '*' || ch == '_' {
|
||||
// Skip consecutive * or _
|
||||
while chars.peek() == Some(&ch) {
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip strikethrough ~~
|
||||
if ch == '~' && chars.peek() == Some(&'~') {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip inline code backticks
|
||||
if ch == '`' {
|
||||
while chars.peek() == Some(&'`') {
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
// Collapse multiple spaces/newlines into single spaces
|
||||
let mut collapsed = String::with_capacity(result.len());
|
||||
let mut last_was_space = false;
|
||||
for ch in result.chars() {
|
||||
if ch.is_whitespace() {
|
||||
if !last_was_space {
|
||||
collapsed.push(' ');
|
||||
last_was_space = true;
|
||||
}
|
||||
} else {
|
||||
collapsed.push(ch);
|
||||
last_was_space = false;
|
||||
}
|
||||
}
|
||||
|
||||
collapsed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_preview_basic() {
|
||||
let content =
|
||||
"## SIM Kaarten\n\n### Artjom\n\n**Telefoonnummer**\n\n**== 0456 74 49 91 ==**\n";
|
||||
let preview = extract_preview(content, 120);
|
||||
eprintln!("Preview: {:?}", preview);
|
||||
assert!(!preview.is_empty(), "Preview should not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_preview_no_frontmatter() {
|
||||
let content = "# Android flash\n\nAndroid flash\n\n[TWRP](https://twrp.me/)\n\nNotes;\n\n- Samsung S6\n";
|
||||
let preview = extract_preview(content, 120);
|
||||
eprintln!("Preview: {:?}", preview);
|
||||
assert!(!preview.is_empty(), "Preview should not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_markdown() {
|
||||
let input = "**Telefoonnummer**\n\n**== 0456 ==**";
|
||||
let stripped = strip_html_and_markdown(input);
|
||||
eprintln!("Stripped: {:?}", stripped);
|
||||
assert!(stripped.contains("Telefoonnummer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod frontmatter;
|
||||
pub mod operations;
|
||||
pub mod watcher;
|
||||
@@ -0,0 +1,677 @@
|
||||
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState};
|
||||
use crate::vault::frontmatter;
|
||||
use chrono::Utc;
|
||||
use rayon::prelude::*;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub fn helixnotes_dir(vault_path: &str) -> PathBuf {
|
||||
Path::new(vault_path).join(".helixnotes")
|
||||
}
|
||||
|
||||
pub fn ensure_vault_structure(vault_path: &str) -> Result<(), String> {
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
fs::create_dir_all(hn_dir.join("trash")).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(hn_dir.join("attachments")).map_err(|e| e.to_string())?;
|
||||
|
||||
let config_path = hn_dir.join("config.json");
|
||||
if !config_path.exists() {
|
||||
let config = serde_json::json!({
|
||||
"version": "0.1.0",
|
||||
"created": Utc::now().to_rfc3339()
|
||||
});
|
||||
fs::write(&config_path, serde_json::to_string_pretty(&config).unwrap())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let state_path = hn_dir.join("state.json");
|
||||
if !state_path.exists() {
|
||||
let state = VaultState::default();
|
||||
fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let gitignore_path = hn_dir.join(".gitignore");
|
||||
if !gitignore_path.exists() {
|
||||
fs::write(&gitignore_path, "trash/\nindex.json\nstate.json\n")
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
|
||||
let root = Path::new(vault_path);
|
||||
if !root.exists() {
|
||||
return Err("Vault path does not exist".to_string());
|
||||
}
|
||||
Ok(scan_dir_recursive(root, vault_path))
|
||||
}
|
||||
|
||||
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let root = Path::new(vault_root);
|
||||
|
||||
let Ok(read_dir) = fs::read_dir(dir) else {
|
||||
return entries;
|
||||
};
|
||||
|
||||
let mut dirs: Vec<_> = read_dir
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let path = e.path();
|
||||
path.is_dir() && !is_hidden(&path)
|
||||
})
|
||||
.collect();
|
||||
|
||||
dirs.sort_by(|a, b| {
|
||||
a.file_name()
|
||||
.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
});
|
||||
|
||||
for dir_entry in dirs {
|
||||
let path = dir_entry.path();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let children = scan_dir_recursive(&path, vault_root);
|
||||
let note_count = count_notes_in_dir(&path);
|
||||
|
||||
entries.push(NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count,
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn count_notes_in_dir(dir: &Path) -> usize {
|
||||
fs::read_dir(dir)
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let path = e.path();
|
||||
path.is_file() && path.extension().and_then(|x| x.to_str()) == Some("md")
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn is_hidden(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
|
||||
let scan_path = notebook_path.unwrap_or(vault_path);
|
||||
let root = Path::new(scan_path);
|
||||
let vault_root = Path::new(vault_path);
|
||||
|
||||
if !root.exists() {
|
||||
return Err("Path does not exist".to_string());
|
||||
}
|
||||
|
||||
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
|
||||
// Only direct children for a specific notebook
|
||||
fs::read_dir(root)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect()
|
||||
} else {
|
||||
// Recursive for "All Notes"
|
||||
WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Process files in parallel with partial reads
|
||||
let mut notes: Vec<NoteEntry> = md_files
|
||||
.par_iter()
|
||||
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
|
||||
.collect();
|
||||
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
|
||||
let raw = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
read_note_entry_from_str(&raw, path, vault_root)
|
||||
}
|
||||
|
||||
/// Fast version: reads only the first ~2KB of the file (enough for frontmatter + preview).
|
||||
fn read_note_entry_fast(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
|
||||
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
buf.truncate(bytes_read);
|
||||
let raw = String::from_utf8_lossy(&buf);
|
||||
read_note_entry_from_str(&raw, path, vault_root)
|
||||
}
|
||||
|
||||
fn read_note_entry_from_str(
|
||||
raw: &str,
|
||||
path: &Path,
|
||||
vault_root: &Path,
|
||||
) -> Result<NoteEntry, String> {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (mut meta, content) = frontmatter::parse_note(raw, &filename);
|
||||
|
||||
// Use filesystem times if frontmatter didn't have them
|
||||
if let Ok(fs_meta) = fs::metadata(path) {
|
||||
if let Ok(modified) = fs_meta.modified() {
|
||||
meta.modified = modified.into();
|
||||
}
|
||||
if let Ok(created) = fs_meta.created() {
|
||||
meta.created = created.into();
|
||||
}
|
||||
}
|
||||
|
||||
let relative = path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let preview = frontmatter::extract_preview(&content, 120);
|
||||
|
||||
Ok(NoteEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
meta,
|
||||
preview,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
||||
let p = Path::new(path);
|
||||
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||
let filename = p
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
Ok(NoteContent {
|
||||
path: path.to_string(),
|
||||
meta,
|
||||
content,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
|
||||
let mut updated_meta = meta.clone();
|
||||
updated_meta.modified = Utc::now();
|
||||
|
||||
let raw = frontmatter::update_note_raw(&updated_meta, body);
|
||||
fs::write(path, raw).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_note(
|
||||
vault_path: &str,
|
||||
notebook_relative: Option<&str>,
|
||||
title: &str,
|
||||
) -> Result<NoteEntry, String> {
|
||||
let dir = match notebook_relative {
|
||||
Some(rel) => Path::new(vault_path).join(rel),
|
||||
None => PathBuf::from(vault_path),
|
||||
};
|
||||
|
||||
if !dir.exists() {
|
||||
return Err("Notebook directory does not exist".to_string());
|
||||
}
|
||||
|
||||
let filename = sanitize_filename(title);
|
||||
let mut file_path = dir.join(format!("{}.md", filename));
|
||||
|
||||
// Deduplicate filename
|
||||
let mut counter = 1;
|
||||
while file_path.exists() {
|
||||
file_path = dir.join(format!("{} {}.md", filename, counter));
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let meta = NoteMeta {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
title: title.to_string(),
|
||||
tags: Vec::new(),
|
||||
pinned: false,
|
||||
created: now,
|
||||
modified: now,
|
||||
};
|
||||
|
||||
let raw = frontmatter::update_note_raw(&meta, "\n");
|
||||
fs::write(&file_path, raw).map_err(|e| e.to_string())?;
|
||||
|
||||
let vault_root = Path::new(vault_path);
|
||||
let relative = file_path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(&file_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(NoteEntry {
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
meta,
|
||||
preview: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_notebook(
|
||||
vault_path: &str,
|
||||
parent_relative: Option<&str>,
|
||||
name: &str,
|
||||
) -> Result<NotebookEntry, String> {
|
||||
let parent = match parent_relative {
|
||||
Some(rel) => Path::new(vault_path).join(rel),
|
||||
None => PathBuf::from(vault_path),
|
||||
};
|
||||
|
||||
let dir_path = parent.join(name);
|
||||
if dir_path.exists() {
|
||||
return Err("Notebook already exists".to_string());
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dir_path).map_err(|e| e.to_string())?;
|
||||
|
||||
let vault_root = Path::new(vault_path);
|
||||
let relative = dir_path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(&dir_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(NotebookEntry {
|
||||
name: name.to_string(),
|
||||
path: dir_path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children: Vec::new(),
|
||||
note_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
||||
let src = Path::new(note_path);
|
||||
if !src.exists() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let filename = src
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d%H%M%S%3f");
|
||||
let trash_name = format!("{}_{}", timestamp, filename);
|
||||
let dest = trash_dir.join(&trash_name);
|
||||
|
||||
fs::rename(src, dest).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), String> {
|
||||
let src = Path::new(notebook_path);
|
||||
if !src.exists() {
|
||||
return Err("Notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let dirname = src
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d%H%M%S%3f");
|
||||
let trash_name = format!("{}_{}", timestamp, dirname);
|
||||
let dest = trash_dir.join(&trash_name);
|
||||
|
||||
fs::rename(src, dest).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.exists() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
// Update frontmatter
|
||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||
let filename = src
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
meta.title = new_title.to_string();
|
||||
meta.modified = Utc::now();
|
||||
let updated = frontmatter::update_note_raw(&meta, &content);
|
||||
|
||||
// Rename file
|
||||
let new_filename = sanitize_filename(new_title);
|
||||
let new_path = src.parent().unwrap().join(format!("{}.md", new_filename));
|
||||
fs::write(src, &updated).map_err(|e| e.to_string())?;
|
||||
|
||||
if new_path != src {
|
||||
fs::rename(src, &new_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(new_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.exists() {
|
||||
return Err("Notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let new_path = src.parent().unwrap().join(new_name);
|
||||
if new_path.exists() {
|
||||
return Err("A notebook with that name already exists".to_string());
|
||||
}
|
||||
|
||||
fs::rename(src, &new_path).map_err(|e| e.to_string())?;
|
||||
Ok(new_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String> {
|
||||
let src = Path::new(note_path);
|
||||
if !src.exists() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
let dest_dir = Path::new(dest_notebook);
|
||||
if !dest_dir.is_dir() {
|
||||
return Err("Destination notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let filename = src.file_name().unwrap_or_default();
|
||||
let dest = dest_dir.join(filename);
|
||||
|
||||
fs::rename(src, &dest).map_err(|e| e.to_string())?;
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
if !trash_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let vault_root = Path::new(vault_path);
|
||||
let mut notes = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
if let Ok(note) = read_note_entry(&path, vault_root) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
pub fn restore_note(
|
||||
vault_path: &str,
|
||||
trash_path: &str,
|
||||
dest_notebook: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let src = Path::new(trash_path);
|
||||
if !src.exists() {
|
||||
return Err("Trashed note does not exist".to_string());
|
||||
}
|
||||
|
||||
let dest_dir = match dest_notebook {
|
||||
Some(nb) => PathBuf::from(nb),
|
||||
None => PathBuf::from(vault_path),
|
||||
};
|
||||
|
||||
// Strip timestamp prefix from trash filename (17-char with millis or 14-char legacy)
|
||||
let filename = src.file_name().unwrap_or_default().to_string_lossy();
|
||||
let original_name = if filename.len() > 18 && filename.chars().nth(17) == Some('_') {
|
||||
&filename[18..]
|
||||
} else if filename.len() > 15 && filename.chars().nth(14) == Some('_') {
|
||||
&filename[15..]
|
||||
} else {
|
||||
&filename
|
||||
};
|
||||
|
||||
let dest = dest_dir.join(original_name);
|
||||
fs::rename(src, &dest).map_err(|e| e.to_string())?;
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn permanent_delete(path: &str) -> Result<(), String> {
|
||||
let p = Path::new(path);
|
||||
if p.is_dir() {
|
||||
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
fs::remove_file(p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn empty_trash(vault_path: &str) -> Result<(), String> {
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
if trash_dir.exists() {
|
||||
fs::remove_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_vault_state(vault_path: &str) -> Result<VaultState, String> {
|
||||
let state_path = helixnotes_dir(vault_path).join("state.json");
|
||||
if state_path.exists() {
|
||||
let data = fs::read_to_string(&state_path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(VaultState::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_vault_state(vault_path: &str, state: &VaultState) -> Result<(), String> {
|
||||
let state_path = helixnotes_dir(vault_path).join("state.json");
|
||||
let data = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
|
||||
fs::write(&state_path, data).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_tags(vault_path: &str) -> Result<Vec<(String, usize)>, String> {
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
let md_files: Vec<PathBuf> = WalkDir::new(vault_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let p = e.path();
|
||||
p.is_file()
|
||||
&& p.extension().and_then(|x| x.to_str()) == Some("md")
|
||||
&& !is_hidden(p)
|
||||
&& !p.starts_with(&hn_dir)
|
||||
})
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect();
|
||||
|
||||
// Read frontmatter in parallel (only need tags, so partial read is fine)
|
||||
let all_tags: Vec<Vec<String>> = md_files
|
||||
.par_iter()
|
||||
.filter_map(|path| {
|
||||
let mut file = fs::File::open(path).ok()?;
|
||||
let mut buf = vec![0u8; 1024]; // Tags are in frontmatter, 1KB is plenty
|
||||
let n = file.read(&mut buf).ok()?;
|
||||
buf.truncate(n);
|
||||
let raw = String::from_utf8_lossy(&buf);
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
let (meta, _) = frontmatter::parse_note(&raw, &filename);
|
||||
Some(meta.tags)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tag_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for tags in all_tags {
|
||||
for tag in tags {
|
||||
*tag_counts.entry(tag).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
|
||||
tags.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
pub fn save_image(vault_path: &str, name: &str, data: &[u8]) -> Result<String, String> {
|
||||
save_attachment(vault_path, name, data)
|
||||
}
|
||||
|
||||
pub fn save_attachment(vault_path: &str, name: &str, data: &[u8]) -> Result<String, String> {
|
||||
let attachments_dir = helixnotes_dir(vault_path).join("attachments");
|
||||
fs::create_dir_all(&attachments_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d%H%M%S");
|
||||
let safe_name = sanitize_filename(name);
|
||||
let filename = format!("{}_{}", timestamp, safe_name);
|
||||
let dest = attachments_dir.join(&filename);
|
||||
|
||||
fs::write(&dest, data).map_err(|e| e.to_string())?;
|
||||
|
||||
// Return relative path from vault root
|
||||
let relative = format!(".helixnotes/attachments/{}", filename);
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
pub fn load_notebook_icons(
|
||||
vault_path: &str,
|
||||
) -> Result<std::collections::HashMap<String, String>, String> {
|
||||
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
|
||||
if icons_path.exists() {
|
||||
let data = fs::read_to_string(&icons_path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(std::collections::HashMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_notebook_icon(
|
||||
vault_path: &str,
|
||||
notebook_relative: &str,
|
||||
icon_relative: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut icons = load_notebook_icons(vault_path)?;
|
||||
match icon_relative {
|
||||
Some(icon) => {
|
||||
icons.insert(notebook_relative.to_string(), icon.to_string());
|
||||
}
|
||||
None => {
|
||||
icons.remove(notebook_relative);
|
||||
}
|
||||
}
|
||||
let icons_path = helixnotes_dir(vault_path).join("notebook_icons.json");
|
||||
let data = serde_json::to_string_pretty(&icons).map_err(|e| e.to_string())?;
|
||||
fs::write(&icons_path, data).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_quick_access(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let qa_path = helixnotes_dir(vault_path).join("quick_access.json");
|
||||
if qa_path.exists() {
|
||||
let data = fs::read_to_string(&qa_path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&data).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_quick_access(vault_path: &str, paths: &[String]) -> Result<(), String> {
|
||||
let qa_path = helixnotes_dir(vault_path).join("quick_access.json");
|
||||
let data = serde_json::to_string_pretty(paths).map_err(|e| e.to_string())?;
|
||||
fs::write(&qa_path, data).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_quick_access(vault_path: &str, note_relative: &str) -> Result<(), String> {
|
||||
let mut list = load_quick_access(vault_path)?;
|
||||
if !list.contains(¬e_relative.to_string()) {
|
||||
list.push(note_relative.to_string());
|
||||
save_quick_access(vault_path, &list)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_quick_access(vault_path: &str, note_relative: &str) -> Result<(), String> {
|
||||
let mut list = load_quick_access(vault_path)?;
|
||||
list.retain(|p| p != note_relative);
|
||||
save_quick_access(vault_path, &list)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_quick_access_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||
let list = load_quick_access(vault_path)?;
|
||||
let vault_root = Path::new(vault_path);
|
||||
let mut notes = Vec::new();
|
||||
|
||||
for relative in &list {
|
||||
let full_path = vault_root.join(relative);
|
||||
if full_path.exists() {
|
||||
if let Ok(entry) = read_note_entry(&full_path, vault_root) {
|
||||
notes.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
pub fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
|
||||
_ => c,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::types::FileEvent;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
|
||||
pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWatcher, String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
tx,
|
||||
Config::default().with_poll_interval(Duration::from_secs(1)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
watcher
|
||||
.watch(Path::new(&vault_path), RecursiveMode::Recursive)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let hn_dir = helixnotes_dir(&vault_path);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(result) = rx.recv() {
|
||||
match result {
|
||||
Ok(event) => {
|
||||
// Skip all file events while importing
|
||||
let state = app.state::<AppState>();
|
||||
if state.importing.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
// Skip .helixnotes directory events
|
||||
let dominated_by_hn = event.paths.iter().all(|p| p.starts_with(&hn_dir));
|
||||
if dominated_by_hn {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only care about .md file events
|
||||
let has_md = event.paths.iter().any(|p| {
|
||||
p.extension().and_then(|x| x.to_str()) == Some("md") || p.is_dir()
|
||||
});
|
||||
|
||||
if !has_md {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event_type = match event.kind {
|
||||
EventKind::Create(_) => "create",
|
||||
EventKind::Modify(_) => "modify",
|
||||
EventKind::Remove(_) => "remove",
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
for path in &event.paths {
|
||||
let fe = FileEvent {
|
||||
event_type: event_type.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
};
|
||||
let _ = app.emit("file-changed", &fe);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("File watcher error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(watcher)
|
||||
}
|
||||
Reference in New Issue
Block a user