v1.0.0 - Initial release

This commit is contained in:
Yuri Karamian
2026-02-09 01:47:56 +01:00
commit 46f4b6e7bc
70 changed files with 26357 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+6885
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "helixnotes"
version = "1.0.0"
description = "Local-first markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
edition = "2021"
rust-version = "1.77.2"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
log = "0.4"
notify = "8"
walkdir = "2"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
tantivy = "0.22"
gray_matter = "0.2"
tokio = { version = "1", features = ["full"] }
dirs = "6"
regex = "1"
zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
futures = "0.3"
rayon = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "HelixNotes default capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-set-decorations",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"fs:default",
"fs:allow-read",
"fs:allow-write",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-copy-file",
"fs:allow-stat",
"fs:allow-read-dir",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-watch",
"opener:default",
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "/**" }]
},
{
"identifier": "opener:allow-open-url",
"allow": [
{ "url": "https://**" },
{ "url": "http://**" },
{ "url": "mailto:*" }
]
},
"opener:allow-reveal-item-in-dir",
"updater:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+405
View File
@@ -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))
}
}
+269
View File
@@ -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
+171
View File
@@ -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 = &timestamp[..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(())
}
+138
View File
@@ -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(())
}
+6
View File
@@ -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();
}
+175
View File
@@ -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)
}
}
+23
View File
@@ -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),
}
}
}
+244
View File
@@ -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,
}
+315
View File
@@ -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: ![alt](url)
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"));
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod frontmatter;
pub mod operations;
pub mod watcher;
+677
View File
@@ -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(&note_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()
}
+73
View File
@@ -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)
}
+60
View File
@@ -0,0 +1,60 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.0.0",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
},
"app": {
"windows": [
{
"title": "HelixNotes",
"label": "main",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 500,
"resizable": true,
"fullscreen": false,
"decorations": false
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'",
"assetProtocol": {
"enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"]
}
}
},
"plugins": {
"updater": {
"endpoints": ["https://helixnotes.com/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFEODY0N0JDRjFEOTVGQTEKUldTaFg5bnh2RWVHclFEcW4wdjB2cWg0TWZNRkRxZThMYnZEV0pKV09YK1ZxcHBYOUJwT1pGT3UK"
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": "v1Compatible",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"linux": {
"deb": {
"depends": []
},
"appimage": {
"bundleMediaFramework": false
}
}
}
}