mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-20 09:57:29 +02:00
chore(release): prepare v1.3.5
This commit is contained in:
+28
-16
@@ -8,6 +8,7 @@ const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn ai_request(
|
||||
app: AppHandle,
|
||||
provider: String,
|
||||
@@ -22,7 +23,11 @@ pub fn ai_request(
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
|
||||
let key_opt = if api_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(api_key.as_str())
|
||||
};
|
||||
let result = match provider.as_str() {
|
||||
"openai" => {
|
||||
stream_openai(
|
||||
@@ -98,7 +103,10 @@ pub fn ai_request(
|
||||
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||
fn normalize_openai_base(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
|
||||
b.strip_suffix("/v1")
|
||||
.unwrap_or(b)
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn stream_anthropic(
|
||||
@@ -268,9 +276,7 @@ async fn stream_openai(
|
||||
body["temperature"] = json!(0.7);
|
||||
}
|
||||
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
let mut req = client.post(url).header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
@@ -380,7 +386,11 @@ pub async fn test_connection(
|
||||
model: &str,
|
||||
base_url: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
|
||||
let key_opt = if api_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(api_key)
|
||||
};
|
||||
match provider {
|
||||
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
||||
"ollama" => {
|
||||
@@ -434,14 +444,18 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
async fn test_openai(url: &str, api_key: Option<&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 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: 20,
|
||||
"messages": [
|
||||
let body = json!({
|
||||
"model": model,
|
||||
token_key: 20,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi"
|
||||
@@ -449,9 +463,7 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
|
||||
]
|
||||
});
|
||||
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
let mut req = client.post(url).header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
|
||||
+48
-15
@@ -134,7 +134,7 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map_or(false, |ext| ext == "zip") {
|
||||
if path.extension().is_some_and(|ext| ext == "zip") {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
@@ -184,14 +184,28 @@ pub fn list_backups(backup_dir: &Path) -> Result<Vec<BackupEntry>, String> {
|
||||
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());
|
||||
fn validated_backup_file(backup_dir: &Path, backup_path: &str) -> Result<PathBuf, String> {
|
||||
let backup_dir = fs::canonicalize(backup_dir).map_err(|error| error.to_string())?;
|
||||
let backup = fs::canonicalize(backup_path).map_err(|error| error.to_string())?;
|
||||
if backup.parent() != Some(backup_dir.as_path())
|
||||
|| backup.extension().and_then(|extension| extension.to_str()) != Some("zip")
|
||||
|| !backup.is_file()
|
||||
{
|
||||
return Err(
|
||||
"Backup path must point to a ZIP file in the configured backup directory".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
/// Restore a backup by extracting the zip over the vault directory
|
||||
pub fn restore_backup(
|
||||
vault_path: &str,
|
||||
backup_dir: &Path,
|
||||
backup_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let backup = validated_backup_file(backup_dir, backup_path)?;
|
||||
|
||||
let file = fs::File::open(backup).map_err(|e| format!("Failed to open backup: {}", e))?;
|
||||
let mut archive =
|
||||
@@ -245,12 +259,9 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
pub fn delete_backup(backup_dir: &Path, backup_path: &str) -> Result<(), String> {
|
||||
let path = validated_backup_file(backup_dir, backup_path)?;
|
||||
fs::remove_file(path).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Remove old backups keeping only the newest `max_count`
|
||||
@@ -261,9 +272,31 @@ pub fn cleanup_old_backups(backup_dir: &Path, max_count: u32) -> Result<(), Stri
|
||||
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)?;
|
||||
delete_backup(backup_dir, &entry.path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::delete_backup;
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn delete_backup_rejects_files_outside_backup_directory() {
|
||||
let root = std::env::temp_dir().join(format!("helixnotes-backup-test-{}", Uuid::new_v4()));
|
||||
let backup_dir = root.join("backups");
|
||||
let outside = root.join("outside.zip");
|
||||
fs::create_dir_all(&backup_dir).unwrap();
|
||||
fs::write(&outside, "must survive").unwrap();
|
||||
|
||||
let result = delete_backup(&backup_dir, &outside.to_string_lossy());
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(outside.exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+359
-184
@@ -6,19 +6,23 @@ use std::path::Path;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
fn index_note_bg(state: &State<'_, AppState>, path: &str) {
|
||||
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||
if let Some(search) = search {
|
||||
let p = path.to_string();
|
||||
std::thread::spawn(move || { let _ = search.index_note(&p); });
|
||||
}
|
||||
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||
if let Some(search) = search {
|
||||
let p = path.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let _ = search.index_note(&p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_note_bg(state: &State<'_, AppState>, path: &str) {
|
||||
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||
if let Some(search) = search {
|
||||
let p = path.to_string();
|
||||
std::thread::spawn(move || { let _ = search.remove_note(&p); });
|
||||
}
|
||||
let search = state.search_index.lock().ok().and_then(|g| g.clone());
|
||||
if let Some(search) = search {
|
||||
let p = path.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let _ = search.remove_note(&p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_vault_runtime(state: &State<'_, AppState>) -> Result<(), String> {
|
||||
@@ -189,9 +193,7 @@ pub async fn choose_external_vault(
|
||||
if bookmark_was_registered {
|
||||
let _ = app.ios_vault_access().rollback_staged();
|
||||
} else {
|
||||
let _ = app
|
||||
.ios_vault_access()
|
||||
.forget_bookmark(&result.bookmark_id);
|
||||
let _ = app.ios_vault_access().forget_bookmark(&result.bookmark_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
@@ -353,7 +355,10 @@ pub fn set_accent_color(state: State<'_, AppState>, color: String) -> Result<(),
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_custom_theme(state: State<'_, AppState>, theme: crate::types::CustomTheme) -> Result<(), String> {
|
||||
pub fn save_custom_theme(
|
||||
state: State<'_, AppState>,
|
||||
theme: crate::types::CustomTheme,
|
||||
) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
|
||||
config.custom_themes[pos] = theme;
|
||||
@@ -391,10 +396,12 @@ mod custom_theme_reference_tests {
|
||||
|
||||
#[test]
|
||||
fn deleting_custom_theme_resets_system_pair_references() {
|
||||
let mut config = AppConfig::default();
|
||||
config.theme = "custom-work".to_string();
|
||||
config.system_light_theme = "custom-work".to_string();
|
||||
config.system_dark_theme = "custom-work".to_string();
|
||||
let mut config = AppConfig {
|
||||
theme: "custom-work".to_string(),
|
||||
system_light_theme: "custom-work".to_string(),
|
||||
system_dark_theme: "custom-work".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
clear_custom_theme_references(&mut config, "custom-work");
|
||||
|
||||
@@ -405,9 +412,16 @@ mod custom_theme_reference_tests {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_custom_theme(state: State<'_, AppState>, id: String, path: String) -> Result<(), String> {
|
||||
pub fn export_custom_theme(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let theme = config.custom_themes.iter().find(|t| t.id == id)
|
||||
let theme = config
|
||||
.custom_themes
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| "Theme not found".to_string())?;
|
||||
let export = serde_json::json!({ "version": 1, "themes": [theme] });
|
||||
let data = serde_json::to_string_pretty(&export).map_err(|e| e.to_string())?;
|
||||
@@ -416,11 +430,15 @@ pub fn export_custom_theme(state: State<'_, AppState>, id: String, path: String)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_custom_themes(state: State<'_, AppState>, path: String) -> Result<Vec<crate::types::CustomTheme>, String> {
|
||||
pub fn import_custom_themes(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
) -> Result<Vec<crate::types::CustomTheme>, String> {
|
||||
let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
|
||||
let themes: Vec<crate::types::CustomTheme> = serde_json::from_value(parsed["themes"].clone())
|
||||
.map_err(|e| format!("Invalid theme file: {}", e))?;
|
||||
let themes: Vec<crate::types::CustomTheme> =
|
||||
serde_json::from_value(parsed["themes"].clone())
|
||||
.map_err(|e| format!("Invalid theme file: {}", e))?;
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
for theme in &themes {
|
||||
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
|
||||
@@ -462,11 +480,7 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_ui_scale(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
scale: f64,
|
||||
) -> Result<(), String> {
|
||||
pub fn set_ui_scale(app: AppHandle, state: State<'_, AppState>, scale: f64) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.ui_scale = Some(scale);
|
||||
save_app_config(&config)?;
|
||||
@@ -513,8 +527,14 @@ pub fn create_notebook(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_notebook(path: String, new_name: String) -> Result<String, String> {
|
||||
operations::rename_notebook(&path, &new_name)
|
||||
pub fn rename_notebook(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
new_name: String,
|
||||
) -> Result<String, String> {
|
||||
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
operations::rename_notebook(vault_path, &path, &new_name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -531,7 +551,7 @@ pub fn move_notebook(
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let new_full_path = operations::move_notebook(¬ebook_path, &dest_parent)?;
|
||||
let new_full_path = operations::move_notebook(vault_path, ¬ebook_path, &dest_parent)?;
|
||||
|
||||
let new_relative = Path::new(&new_full_path)
|
||||
.strip_prefix(vault_path.as_str())
|
||||
@@ -594,7 +614,11 @@ pub fn move_notebook(
|
||||
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||
let vault_path = {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.active_vault.as_ref().ok_or("No active vault")?.clone()
|
||||
config
|
||||
.active_vault
|
||||
.as_ref()
|
||||
.ok_or("No active vault")?
|
||||
.clone()
|
||||
};
|
||||
operations::delete_notebook(&vault_path, &path)
|
||||
}
|
||||
@@ -612,8 +636,10 @@ pub fn get_notes(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_note(path: String) -> Result<NoteContent, String> {
|
||||
operations::read_note(&path)
|
||||
pub fn read_note(state: State<'_, AppState>, path: String) -> Result<NoteContent, String> {
|
||||
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
operations::read_note(vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -623,27 +649,26 @@ pub fn save_note(
|
||||
meta: NoteMeta,
|
||||
body: String,
|
||||
) -> Result<(), String> {
|
||||
// Snapshot current content before overwriting (if file exists)
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(vault_path) = &config.active_vault {
|
||||
if std::path::Path::new(&path).exists() {
|
||||
if let Ok(old_raw) = std::fs::read_to_string(&path) {
|
||||
let max_versions = config.max_versions_per_note;
|
||||
let note_id = meta.id.clone();
|
||||
let vp = vault_path.clone();
|
||||
// Snapshot in background so save isn't slowed down
|
||||
std::thread::spawn(move || {
|
||||
crate::history::maybe_snapshot(&vp, ¬e_id, &old_raw, max_versions);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||
let vault_path = config
|
||||
.active_vault
|
||||
.as_ref()
|
||||
.ok_or("No active vault")?
|
||||
.clone();
|
||||
let max_versions = config.max_versions_per_note;
|
||||
let old_raw = operations::read_vault_note(&vault_path, &path)?.raw;
|
||||
drop(config);
|
||||
|
||||
operations::save_note(&path, &meta, &body)?;
|
||||
let note_id = meta.id.clone();
|
||||
let snapshot_vault = vault_path.clone();
|
||||
std::thread::spawn(move || {
|
||||
crate::history::maybe_snapshot(&snapshot_vault, ¬e_id, &old_raw, max_versions);
|
||||
});
|
||||
|
||||
// Re-index note so search picks up changes (background to avoid blocking on FUSE fsync)
|
||||
index_note_bg(&state, &path);
|
||||
operations::save_note(&vault_path, &path, &meta, &body)?;
|
||||
|
||||
// Re-index note so search picks up changes (background to avoid blocking on FUSE fsync)
|
||||
index_note_bg(&state, &path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -654,7 +679,11 @@ pub fn duplicate_note(
|
||||
path: String,
|
||||
) -> Result<crate::types::NoteEntry, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault = config.active_vault.as_ref().ok_or("No active vault")?.clone();
|
||||
let vault = config
|
||||
.active_vault
|
||||
.as_ref()
|
||||
.ok_or("No active vault")?
|
||||
.clone();
|
||||
drop(config);
|
||||
|
||||
let entry = operations::duplicate_note(&path, &vault)?;
|
||||
@@ -668,31 +697,43 @@ pub fn create_note(
|
||||
notebook_relative: Option<String>,
|
||||
title: String,
|
||||
) -> Result<NoteEntry, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
let entry = operations::create_note(vault_path, notebook_relative.as_deref(), &title)?;
|
||||
|
||||
// Index new note (background to avoid blocking on FUSE fsync)
|
||||
index_note_bg(&state, &entry.path);
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
|
||||
let entry = operations::create_note(vault_path, notebook_relative.as_deref(), &title)?;
|
||||
|
||||
index_note_bg(&state, &entry.path);
|
||||
// Index new note (background to avoid blocking on FUSE fsync)
|
||||
index_note_bg(&state, &entry.path);
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note(state: State<'_, AppState>, path: String, new_title: String) -> Result<String, String> {
|
||||
pub fn create_daily_note(
|
||||
state: State<'_, AppState>,
|
||||
date: Option<String>,
|
||||
) -> Result<NoteEntry, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?.clone();
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
let entry =
|
||||
operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
|
||||
|
||||
index_note_bg(&state, &entry.path);
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note(
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
new_title: String,
|
||||
) -> Result<String, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config
|
||||
.active_vault
|
||||
.as_ref()
|
||||
.ok_or("No active vault")?
|
||||
.clone();
|
||||
drop(config);
|
||||
operations::rename_note(&path, &new_title, &vault_path)
|
||||
}
|
||||
@@ -703,8 +744,8 @@ pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), Strin
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
operations::delete_note(vault_path, &path)?;
|
||||
|
||||
// Remove from index (background to avoid blocking on FUSE fsync)
|
||||
remove_note_bg(&state, &path);
|
||||
// Remove from index (background to avoid blocking on FUSE fsync)
|
||||
remove_note_bg(&state, &path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -724,7 +765,7 @@ pub fn move_note(
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let new_full_path = operations::move_note(¬e_path, &dest_notebook)?;
|
||||
let new_full_path = operations::move_note(vault_path, ¬e_path, &dest_notebook)?;
|
||||
|
||||
// Update quick access if the moved note was in it
|
||||
if !old_relative.is_empty() {
|
||||
@@ -800,10 +841,7 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/").to_string())
|
||||
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||
|
||||
entries.push(NoteTitleEntry {
|
||||
title,
|
||||
path: rel,
|
||||
});
|
||||
entries.push(NoteTitleEntry { title, path: rel });
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
@@ -859,23 +897,34 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() { continue; }
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
// Skip Syncthing conflict files
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.contains(".sync-conflict-") { continue; }
|
||||
if name.contains(".sync-conflict-") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Deduplicate by canonical path (handles symlinks)
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let canonical_str = canonical.to_string_lossy().to_string();
|
||||
if !seen_paths.insert(canonical_str) { continue; }
|
||||
if !seen_paths.insert(canonical_str) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw = std::fs::read_to_string(path).unwrap_or_default();
|
||||
|
||||
// Fast title extraction: scan for "title: " line in frontmatter without full YAML parse
|
||||
let title = extract_title_fast(&raw).unwrap_or_else(|| {
|
||||
path.file_stem().unwrap_or_default().to_string_lossy().to_string()
|
||||
path.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let idx = graph_nodes.len();
|
||||
@@ -887,7 +936,10 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
if let Ok(rel) = path.strip_prefix(vault) {
|
||||
let rel_no_ext = rel.with_extension("");
|
||||
// Normalize Windows backslashes so [[folder/note]] links resolve cross-platform.
|
||||
let rel_lower = rel_no_ext.to_string_lossy().replace('\\', "/").to_lowercase();
|
||||
let rel_lower = rel_no_ext
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.to_lowercase();
|
||||
relpath_to_idx.entry(rel_lower).or_insert(idx);
|
||||
}
|
||||
|
||||
@@ -903,16 +955,27 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
|
||||
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
|
||||
|
||||
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, src: usize, tgt: usize| {
|
||||
if src == tgt { return; }
|
||||
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
|
||||
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>,
|
||||
edge_map: &mut HashMap<(usize, usize), usize>,
|
||||
src: usize,
|
||||
tgt: usize| {
|
||||
if src == tgt {
|
||||
return;
|
||||
}
|
||||
if edge_map.contains_key(&(src, tgt)) {
|
||||
return;
|
||||
} // exact duplicate
|
||||
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
|
||||
// Reverse direction already exists - mark it as bidirectional
|
||||
edges[rev_idx].bidirectional = true;
|
||||
} else {
|
||||
let idx = edges.len();
|
||||
edge_map.insert((src, tgt), idx);
|
||||
edges.push(crate::types::GraphEdge { source: src, target: tgt, bidirectional: false });
|
||||
edges.push(crate::types::GraphEdge {
|
||||
source: src,
|
||||
target: tgt,
|
||||
bidirectional: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -961,25 +1024,32 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
}
|
||||
}
|
||||
|
||||
Ok(crate::types::GraphData { nodes: graph_nodes, edges })
|
||||
Ok(crate::types::GraphData {
|
||||
nodes: graph_nodes,
|
||||
edges,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fast title extraction from frontmatter without full YAML parsing.
|
||||
/// Scans for `title: ...` line within `---` fences.
|
||||
fn extract_title_fast(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim_start();
|
||||
if !trimmed.starts_with("---") { return None; }
|
||||
if !trimmed.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
// Find the closing ---
|
||||
let after_open = &trimmed[3..];
|
||||
let end = after_open.find("\n---")?;
|
||||
let frontmatter = &after_open[..end];
|
||||
for line in frontmatter.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("title:") {
|
||||
let val = line[6..].trim();
|
||||
if let Some(title) = line.strip_prefix("title:") {
|
||||
let val = title.trim();
|
||||
// Strip surrounding quotes
|
||||
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
|
||||
return Some(val[1..val.len()-1].to_string());
|
||||
if (val.starts_with('"') && val.ends_with('"'))
|
||||
|| (val.starts_with('\'') && val.ends_with('\''))
|
||||
{
|
||||
return Some(val[1..val.len() - 1].to_string());
|
||||
}
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
@@ -1022,7 +1092,11 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
|
||||
Ok(r) => r,
|
||||
Err(_) => return out,
|
||||
};
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
||||
let note_path = path.to_string_lossy().to_string();
|
||||
for (i, line) in body.lines().enumerate() {
|
||||
@@ -1035,7 +1109,11 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
|
||||
let due = due_re.captures(&content).map(|c| c[1].to_string());
|
||||
let priority = prio_re.captures(&content).map(|c| {
|
||||
let p = c[1].to_lowercase();
|
||||
if p == "medium" { "med".to_string() } else { p }
|
||||
if p == "medium" {
|
||||
"med".to_string()
|
||||
} else {
|
||||
p
|
||||
}
|
||||
});
|
||||
let mut text = due_re.replace_all(&content, "").to_string();
|
||||
text = prio_re.replace_all(&text, " ").to_string();
|
||||
@@ -1057,6 +1135,21 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
fn read_task_note(
|
||||
state: &State<'_, AppState>,
|
||||
note_path: &str,
|
||||
) -> Result<(String, NoteMeta, String), String> {
|
||||
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||
let vault_path = config
|
||||
.active_vault
|
||||
.as_ref()
|
||||
.ok_or("No active vault")?
|
||||
.clone();
|
||||
drop(config);
|
||||
let note = operations::read_vault_note(&vault_path, note_path)?;
|
||||
Ok((vault_path, note.meta, note.content))
|
||||
}
|
||||
|
||||
fn toggle_checkbox_line(line: &str, done: bool) -> String {
|
||||
let mut s = line.to_string();
|
||||
if done {
|
||||
@@ -1082,12 +1175,8 @@ pub fn set_task_done(
|
||||
raw_line: String,
|
||||
done: bool,
|
||||
) -> Result<(), String> {
|
||||
let p = std::path::Path::new(¬e_path);
|
||||
let raw = std::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, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
|
||||
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||
// Verify the expected line; if the note drifted, fall back to the first exact match.
|
||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
||||
line
|
||||
@@ -1107,9 +1196,9 @@ pub fn set_task_done(
|
||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||
new_body.push('\n');
|
||||
}
|
||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||
|
||||
index_note_bg(&state, ¬e_path);
|
||||
index_note_bg(&state, ¬e_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1140,18 +1229,18 @@ pub fn set_task_priority(
|
||||
Some(_) => return Err("Invalid priority".to_string()),
|
||||
};
|
||||
|
||||
let p = std::path::Path::new(¬e_path);
|
||||
let raw = std::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, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
|
||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
||||
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||
let idx = if lines
|
||||
.get(line)
|
||||
.map(|item| *item == raw_line)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
line
|
||||
} else {
|
||||
lines
|
||||
.iter()
|
||||
.position(|l| *l == raw_line)
|
||||
.position(|item| *item == raw_line)
|
||||
.ok_or("Task line not found (note changed)")?
|
||||
};
|
||||
|
||||
@@ -1164,7 +1253,7 @@ pub fn set_task_priority(
|
||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||
new_body.push('\n');
|
||||
}
|
||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||
|
||||
index_note_bg(&state, ¬e_path);
|
||||
Ok(())
|
||||
@@ -1196,18 +1285,18 @@ pub fn set_task_due(
|
||||
Some(_) => return Err("Invalid due date".to_string()),
|
||||
};
|
||||
|
||||
let p = std::path::Path::new(¬e_path);
|
||||
let raw = std::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, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
|
||||
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
|
||||
let (vault_path, meta, body) = read_task_note(&state, ¬e_path)?;
|
||||
let mut lines: Vec<String> = body.lines().map(|line| line.to_string()).collect();
|
||||
let idx = if lines
|
||||
.get(line)
|
||||
.map(|item| *item == raw_line)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
line
|
||||
} else {
|
||||
lines
|
||||
.iter()
|
||||
.position(|l| *l == raw_line)
|
||||
.position(|item| *item == raw_line)
|
||||
.ok_or("Task line not found (note changed)")?
|
||||
};
|
||||
|
||||
@@ -1220,7 +1309,7 @@ pub fn set_task_due(
|
||||
if body.ends_with('\n') && !new_body.ends_with('\n') {
|
||||
new_body.push('\n');
|
||||
}
|
||||
operations::save_note(¬e_path, &meta, &new_body)?;
|
||||
operations::save_note(&vault_path, ¬e_path, &meta, &new_body)?;
|
||||
|
||||
index_note_bg(&state, ¬e_path);
|
||||
Ok(())
|
||||
@@ -1269,7 +1358,11 @@ pub fn restore_note(
|
||||
) -> Result<String, String> {
|
||||
let vault_path = {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.active_vault.as_ref().ok_or("No active vault")?.clone()
|
||||
config
|
||||
.active_vault
|
||||
.as_ref()
|
||||
.ok_or("No active vault")?
|
||||
.clone()
|
||||
};
|
||||
operations::restore_note(&vault_path, &trash_path, dest_notebook.as_deref())
|
||||
}
|
||||
@@ -1278,7 +1371,11 @@ pub fn restore_note(
|
||||
pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Result<String, String> {
|
||||
let vault_path = {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.active_vault.as_ref().ok_or("No active vault")?.clone()
|
||||
config
|
||||
.active_vault
|
||||
.as_ref()
|
||||
.ok_or("No active vault")?
|
||||
.clone()
|
||||
};
|
||||
operations::restore_notebook(&vault_path, &trash_path)
|
||||
}
|
||||
@@ -1287,7 +1384,11 @@ pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Resul
|
||||
pub fn permanent_delete(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||
let vault_path = {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.active_vault.as_ref().ok_or("No active vault")?.clone()
|
||||
config
|
||||
.active_vault
|
||||
.as_ref()
|
||||
.ok_or("No active vault")?
|
||||
.clone()
|
||||
};
|
||||
operations::permanent_delete(&vault_path, &path)
|
||||
}
|
||||
@@ -1347,8 +1448,11 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
||||
// Encode RGBA data to PNG
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut encoder =
|
||||
png::Encoder::new(std::io::Cursor::new(&mut buf), img.width as u32, img.height as u32);
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::Cursor::new(&mut buf),
|
||||
img.width as u32,
|
||||
img.height as u32,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder
|
||||
@@ -1372,8 +1476,8 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
||||
#[tauri::command]
|
||||
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
|
||||
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let img =
|
||||
image::load_from_memory(&data).map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let img_data = arboard::ImageData {
|
||||
@@ -1381,9 +1485,10 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
|
||||
height: h as usize,
|
||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||
};
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard.set_image(img_data)
|
||||
let mut clipboard =
|
||||
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard
|
||||
.set_image(img_data)
|
||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1398,8 +1503,8 @@ pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let img =
|
||||
image::load_from_memory(&data).map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let img_data = arboard::ImageData {
|
||||
@@ -1407,9 +1512,10 @@ pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
|
||||
height: h as usize,
|
||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||
};
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard.set_image(img_data)
|
||||
let mut clipboard =
|
||||
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard
|
||||
.set_image(img_data)
|
||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1469,6 +1575,7 @@ pub fn set_notebook_icon(
|
||||
// ── General Settings ──
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_general_settings(
|
||||
state: State<'_, AppState>,
|
||||
compact_notes: bool,
|
||||
@@ -1693,7 +1800,11 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let p = entry.path();
|
||||
if p.is_file() {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let name = p
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
files.push((name, size));
|
||||
}
|
||||
@@ -1702,7 +1813,10 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut haystack = String::new();
|
||||
for entry in walkdir::WalkDir::new(vault).into_iter().filter_map(|e| e.ok()) {
|
||||
for entry in walkdir::WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let p = entry.path();
|
||||
if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
if let Ok(content) = std::fs::read_to_string(p) {
|
||||
@@ -1874,7 +1988,6 @@ pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// ── Backup ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1935,15 +2048,18 @@ pub fn list_backups(state: State<'_, AppState>) -> Result<Vec<BackupEntry>, Stri
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_backup(app: AppHandle, backup_path: String) -> Result<(), String> {
|
||||
let vault_path = {
|
||||
let (vault_path, backup_dir) = {
|
||||
let state = app.state::<AppState>();
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.active_vault.clone().ok_or("No active vault")?
|
||||
(
|
||||
config.active_vault.clone().ok_or("No active vault")?,
|
||||
crate::backup::get_backup_dir(&config.backup_location)?,
|
||||
)
|
||||
};
|
||||
|
||||
std::thread::spawn(move || {
|
||||
use tauri::Emitter;
|
||||
match crate::backup::restore_backup(&vault_path, &backup_path) {
|
||||
match crate::backup::restore_backup(&vault_path, &backup_dir, &backup_path) {
|
||||
Ok(()) => {
|
||||
let _ = app.emit(
|
||||
"restore-done",
|
||||
@@ -1967,8 +2083,10 @@ pub fn restore_backup(app: AppHandle, backup_path: String) -> Result<(), String>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_backup(backup_path: String) -> Result<(), String> {
|
||||
crate::backup::delete_backup(&backup_path)
|
||||
pub fn delete_backup(state: State<'_, AppState>, backup_path: String) -> Result<(), String> {
|
||||
let config = state.config.lock().map_err(|error| error.to_string())?;
|
||||
let backup_dir = crate::backup::get_backup_dir(&config.backup_location)?;
|
||||
crate::backup::delete_backup(&backup_dir, &backup_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2011,9 +2129,8 @@ pub fn create_version(
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
let max_versions = config.max_versions_per_note;
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
crate::history::force_snapshot(vault_path, ¬e_id, &raw, max_versions);
|
||||
Ok(())
|
||||
let raw = operations::read_vault_note(vault_path, &path)?.raw;
|
||||
crate::history::force_snapshot(vault_path, ¬e_id, &raw, max_versions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2030,6 +2147,7 @@ pub fn get_note_version_content(
|
||||
// ── AI ──
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_ai_settings(
|
||||
state: State<'_, AppState>,
|
||||
provider: Option<String>,
|
||||
@@ -2050,7 +2168,8 @@ pub fn set_ai_settings(
|
||||
config.ollama_api_key = ollama_api_key.filter(|k| !k.is_empty());
|
||||
}
|
||||
Some("openai_compatible") => {
|
||||
config.openai_compatible_base_url = openai_compatible_base_url.filter(|u| !u.trim().is_empty());
|
||||
config.openai_compatible_base_url =
|
||||
openai_compatible_base_url.filter(|u| !u.trim().is_empty());
|
||||
config.openai_compatible_api_key = openai_compatible_api_key.filter(|k| !k.is_empty());
|
||||
}
|
||||
_ => config.ai_api_key = key,
|
||||
@@ -2073,7 +2192,9 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
||||
.unwrap_or_else(|| "anthropic".to_string());
|
||||
let key = match provider.as_str() {
|
||||
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
|
||||
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
|
||||
"openai_compatible" => {
|
||||
Some(config.openai_compatible_api_key.clone().unwrap_or_default())
|
||||
}
|
||||
"openai" => config.openai_api_key.clone(),
|
||||
_ => config.ai_api_key.clone(),
|
||||
}
|
||||
@@ -2115,11 +2236,7 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
||||
|
||||
// ── Sync (WebDAV) ──
|
||||
|
||||
fn vault_matches_identity(
|
||||
vault: &VaultConfig,
|
||||
path: &str,
|
||||
bookmark_id: Option<&str>,
|
||||
) -> bool {
|
||||
fn vault_matches_identity(vault: &VaultConfig, path: &str, bookmark_id: Option<&str>) -> bool {
|
||||
if let Some(bookmark_id) = bookmark_id {
|
||||
vault.bookmark_id.as_deref() == Some(bookmark_id)
|
||||
} else {
|
||||
@@ -2148,23 +2265,25 @@ mod vault_identity_tests {
|
||||
|
||||
#[test]
|
||||
fn bookmark_identity_disambiguates_vaults_with_the_same_path() {
|
||||
let mut config = AppConfig::default();
|
||||
config.active_vault = Some("/same/path".to_string());
|
||||
config.vaults = vec![
|
||||
VaultConfig {
|
||||
path: "/same/path".to_string(),
|
||||
name: "Local".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
VaultConfig {
|
||||
path: "/same/path".to_string(),
|
||||
name: "Files".to_string(),
|
||||
bookmark_id: Some("bookmark".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let mut config = AppConfig {
|
||||
active_vault: Some("/same/path".to_string()),
|
||||
vaults: vec![
|
||||
VaultConfig {
|
||||
path: "/same/path".to_string(),
|
||||
name: "Local".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
VaultConfig {
|
||||
path: "/same/path".to_string(),
|
||||
name: "Files".to_string(),
|
||||
bookmark_id: Some("bookmark".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
active_bookmark_id: Some("bookmark".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
config.active_bookmark_id = Some("bookmark".to_string());
|
||||
assert_eq!(active_vault_config(&config).unwrap().name, "Files");
|
||||
|
||||
config.active_bookmark_id = None;
|
||||
@@ -2190,6 +2309,7 @@ fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, Str
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_sync_settings(
|
||||
state: State<'_, AppState>,
|
||||
provider: Option<String>,
|
||||
@@ -2262,9 +2382,7 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||
.clone()
|
||||
.ok_or_else(|| "No active vault".to_string())
|
||||
.and_then(|vault| {
|
||||
sync_config_from(&config).map(|cfg| {
|
||||
(vault, config.active_bookmark_id.clone(), cfg)
|
||||
})
|
||||
sync_config_from(&config).map(|cfg| (vault, config.active_bookmark_id.clone(), cfg))
|
||||
});
|
||||
match gathered {
|
||||
Ok(vault_config) => vault_config,
|
||||
@@ -2278,7 +2396,9 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||
std::thread::spawn(move || {
|
||||
use tauri::Emitter;
|
||||
let result = crate::sync::run_sync(app.clone(), vault.clone(), cfg);
|
||||
app.state::<AppState>().syncing.store(false, Ordering::SeqCst);
|
||||
app.state::<AppState>()
|
||||
.syncing
|
||||
.store(false, Ordering::SeqCst);
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
@@ -2296,7 +2416,10 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app.emit("sync-error", serde_json::json!({ "success": false, "error": e }));
|
||||
let _ = app.emit(
|
||||
"sync-error",
|
||||
serde_json::json!({ "success": false, "error": e }),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2320,7 +2443,9 @@ pub fn ai_ask(
|
||||
.unwrap_or_else(|| "anthropic".to_string());
|
||||
let key = match provider.as_str() {
|
||||
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
|
||||
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
|
||||
"openai_compatible" => {
|
||||
Some(config.openai_compatible_api_key.clone().unwrap_or_default())
|
||||
}
|
||||
"openai" => config.openai_api_key.clone(),
|
||||
_ => config.ai_api_key.clone(),
|
||||
}
|
||||
@@ -2425,7 +2550,9 @@ fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
|
||||
if config.sync_provider.is_none() && config.webdav_url.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Ok(active_index) = active_vault_index(config) else { return false; };
|
||||
let Ok(active_index) = active_vault_index(config) else {
|
||||
return false;
|
||||
};
|
||||
let g_provider = config.sync_provider.clone();
|
||||
let g_url = config.webdav_url.clone();
|
||||
let g_user = config.webdav_username.clone();
|
||||
@@ -2449,10 +2576,56 @@ fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn write_private_file(path: &std::path::Path, data: &[u8]) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.create(true).truncate(true).write(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
|
||||
let mut file = options.open(path).map_err(|error| error.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
file.write_all(data).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod config_permission_tests {
|
||||
use super::write_private_file;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn config_files_are_owner_only() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"helixnotes-config-permissions-{}.json",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
std::fs::write(&path, "old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
|
||||
write_private_file(&path, b"secret").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"secret");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn save_app_config(config: &AppConfig) -> Result<(), String> {
|
||||
let path = app_config_path()?;
|
||||
let data = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, data).map_err(|e| e.to_string())?;
|
||||
write_private_file(&path, data.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2489,13 +2662,15 @@ pub fn get_install_type() -> String {
|
||||
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
|
||||
"deb".to_string()
|
||||
} else if std::path::Path::new("/var/lib/pacman/local").exists()
|
||||
&& ["helixnotes", "helixnotes-bin", "helixnotes-appimage-bin"].iter().any(|pkg| {
|
||||
std::process::Command::new("pacman")
|
||||
.args(["-Q", pkg])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
&& ["helixnotes", "helixnotes-bin", "helixnotes-appimage-bin"]
|
||||
.iter()
|
||||
.any(|pkg| {
|
||||
std::process::Command::new("pacman")
|
||||
.args(["-Q", pkg])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
"aur".to_string()
|
||||
} else if std::env::var("APPIMAGE").is_ok() {
|
||||
|
||||
+60
-22
@@ -1,22 +1,36 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::types::VersionEntry;
|
||||
|
||||
fn safe_path_component<'a>(value: &'a str, label: &str) -> Result<&'a str, String> {
|
||||
let mut components = Path::new(value).components();
|
||||
if value.is_empty()
|
||||
|| !matches!(components.next(), Some(Component::Normal(_)))
|
||||
|| components.next().is_some()
|
||||
{
|
||||
return Err(format!("Invalid {label}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Directory: .helixnotes/history/<note-id>/
|
||||
fn history_dir(vault_path: &str, note_id: &str) -> PathBuf {
|
||||
Path::new(vault_path)
|
||||
fn history_dir(vault_path: &str, note_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(Path::new(vault_path)
|
||||
.join(".helixnotes")
|
||||
.join("history")
|
||||
.join(note_id)
|
||||
.join(safe_path_component(note_id, "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);
|
||||
let Ok(dir) = history_dir(vault_path, note_id) else {
|
||||
log::warn!("Skipping history snapshot with an invalid note ID");
|
||||
return;
|
||||
};
|
||||
|
||||
// Check if we should create a snapshot (5 min cooldown)
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
@@ -68,31 +82,30 @@ pub fn maybe_snapshot(vault_path: &str, note_id: &str, raw_content: &str, max_ve
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
pub fn force_snapshot(
|
||||
vault_path: &str,
|
||||
note_id: &str,
|
||||
raw_content: &str,
|
||||
max_versions: u32,
|
||||
) -> Result<(), String> {
|
||||
let dir = history_dir(vault_path, note_id)?;
|
||||
fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
|
||||
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;
|
||||
}
|
||||
fs::write(&path, raw_content).map_err(|error| error.to_string())?;
|
||||
|
||||
if max_versions > 0 {
|
||||
let _ = prune_versions(&dir, max_versions);
|
||||
prune_versions(&dir, max_versions)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let dir = history_dir(vault_path, note_id)?;
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -102,7 +115,7 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
||||
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") {
|
||||
if path.extension().is_some_and(|ext| ext == "md") {
|
||||
let filename = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
@@ -134,17 +147,18 @@ pub fn list_versions(vault_path: &str, note_id: &str) -> Result<Vec<VersionEntry
|
||||
|
||||
/// Get the raw content of a specific version.
|
||||
pub fn get_version(vault_path: &str, note_id: &str, timestamp: &str) -> Result<String, String> {
|
||||
safe_path_component(timestamp, "version timestamp")?;
|
||||
// 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))
|
||||
format!("{date_part}T{time_dashes}.md")
|
||||
} else {
|
||||
format!("{}.md", timestamp)
|
||||
};
|
||||
|
||||
let path = history_dir(vault_path, note_id).join(&filename);
|
||||
let path = history_dir(vault_path, note_id)?.join(&filename);
|
||||
fs::read_to_string(&path).map_err(|e| format!("Version not found: {}", e))
|
||||
}
|
||||
|
||||
@@ -154,7 +168,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
||||
.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"))
|
||||
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
|
||||
.collect();
|
||||
|
||||
// Sort by name (timestamps sort lexicographically) - newest last
|
||||
@@ -169,3 +183,27 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{get_version, list_versions};
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn rejects_history_path_traversal() {
|
||||
let vault =
|
||||
std::env::temp_dir().join(format!("helixnotes-history-test-{}", Uuid::new_v4()));
|
||||
let metadata = vault.join(".helixnotes");
|
||||
let escaped_history = metadata.join("escaped");
|
||||
fs::create_dir_all(&escaped_history).unwrap();
|
||||
fs::write(escaped_history.join("2026-01-01T00-00-00.md"), "escaped").unwrap();
|
||||
fs::create_dir_all(metadata.join("history").join("safe")).unwrap();
|
||||
fs::write(metadata.join("secret.md"), "secret").unwrap();
|
||||
|
||||
assert!(list_versions(&vault.to_string_lossy(), "../escaped").is_err());
|
||||
assert!(get_version(&vault.to_string_lossy(), "safe", "../../secret").is_err());
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ pub fn run() {
|
||||
let external_url = percent_decode(encoded);
|
||||
|
||||
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
||||
let _ = responder.respond(
|
||||
responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(400)
|
||||
.body(Vec::new())
|
||||
@@ -234,7 +234,7 @@ pub fn run() {
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
@@ -255,7 +255,7 @@ pub fn run() {
|
||||
let status = resp.status().as_u16();
|
||||
match resp.bytes() {
|
||||
Ok(bytes) => {
|
||||
let _ = responder.respond(
|
||||
responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", &content_type)
|
||||
@@ -265,7 +265,7 @@ pub fn run() {
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
@@ -275,7 +275,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
@@ -350,9 +350,9 @@ pub fn run() {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
tauri::WindowEvent::Destroyed
|
||||
// When main window is destroyed, close all note windows
|
||||
if window.label() == "main" {
|
||||
if window.label() == "main" => {
|
||||
let app = window.app_handle();
|
||||
for (label, win) in app.webview_windows() {
|
||||
if label.starts_with("note-") {
|
||||
@@ -360,7 +360,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
app_lib::run();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(vault_path.as_bytes());
|
||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let key: String = hasher.finalize()[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect();
|
||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||
}
|
||||
|
||||
@@ -235,7 +238,9 @@ impl SearchIndex {
|
||||
// before the writer is created, so both indexing and querying use it).
|
||||
index.tokenizers().register(
|
||||
"cjk",
|
||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
||||
TextAnalyzer::builder(CjkTokenizer)
|
||||
.filter(LowerCaser)
|
||||
.build(),
|
||||
);
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -290,7 +295,7 @@ impl SearchIndex {
|
||||
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(" "));
|
||||
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
}
|
||||
}
|
||||
@@ -321,7 +326,7 @@ impl SearchIndex {
|
||||
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(" "));
|
||||
doc.add_text(self.tags_field, meta.tags.join(" "));
|
||||
let _ = writer.add_document(doc);
|
||||
|
||||
writer.commit().map_err(|e| e.to_string())?;
|
||||
@@ -341,7 +346,7 @@ impl SearchIndex {
|
||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||
let fields = [self.title_field, self.body_field, self.tags_field];
|
||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||
@@ -376,9 +381,10 @@ impl SearchIndex {
|
||||
));
|
||||
vec![(Occur::Should, exact)]
|
||||
} else {
|
||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
||||
vec![Term::from_field_text(field, term)],
|
||||
));
|
||||
let prefix: Box<dyn Query> =
|
||||
Box::new(PhrasePrefixQuery::new(vec![Term::from_field_text(
|
||||
field, term,
|
||||
)]));
|
||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
1,
|
||||
|
||||
+25
-8
@@ -81,7 +81,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
||||
}
|
||||
|
||||
fn normalize_etag(s: &str) -> String {
|
||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
||||
s.trim()
|
||||
.trim_start_matches("W/")
|
||||
.trim_matches('"')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Percent-encode each path segment, keeping the `/` separators.
|
||||
@@ -265,7 +268,11 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
return Err(format!(
|
||||
"GET {} failed: HTTP {}",
|
||||
relpath,
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||
}
|
||||
@@ -279,7 +286,11 @@ impl WebdavClient {
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
return Err(format!(
|
||||
"PUT {} failed: HTTP {}",
|
||||
relpath,
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
@@ -453,8 +464,10 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if cap != Cap::None {
|
||||
if let Ok(t) = e.unescape() {
|
||||
buf.push_str(&t);
|
||||
if let Ok(decoded) = e.decode() {
|
||||
if let Ok(text) = quick_xml::escape::unescape(&decoded) {
|
||||
buf.push_str(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,8 +528,8 @@ fn apply_changes(
|
||||
|
||||
match (l, r) {
|
||||
(Some(lf), Some(re)) => {
|
||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
||||
let local_changed = m.is_none_or(|me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.is_none_or(|me| &me.remote_etag != re);
|
||||
if !local_changed && !remote_changed {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
@@ -669,7 +682,11 @@ pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
||||
|
||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||
/// rebuilds the search index. Returns a summary of what changed.
|
||||
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
||||
pub fn run_sync(
|
||||
app: tauri::AppHandle,
|
||||
vault: String,
|
||||
cfg: WebdavConfig,
|
||||
) -> Result<SyncSummary, String> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
@@ -549,10 +549,7 @@ mod startup_view_tests {
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("show_note_switcher");
|
||||
value.as_object_mut().unwrap().remove("show_note_switcher");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(!config.show_note_switcher);
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
||||
"[{}]",
|
||||
meta.tags
|
||||
.iter()
|
||||
.map(|t| format!("{}", t))
|
||||
.map(|t| t.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
@@ -279,7 +279,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
chars.next(); // skip '['
|
||||
let mut depth = 1;
|
||||
// Skip alt text
|
||||
while let Some(c) = chars.next() {
|
||||
for c in chars.by_ref() {
|
||||
if c == '[' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -294,7 +294,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
for c in chars.by_ref() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -313,7 +313,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if ch == '[' {
|
||||
let mut link_text = String::new();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
for c in chars.by_ref() {
|
||||
if c == '[' {
|
||||
depth += 1;
|
||||
}
|
||||
@@ -329,7 +329,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
let mut depth = 1;
|
||||
while let Some(c) = chars.next() {
|
||||
for c in chars.by_ref() {
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
@@ -112,13 +112,15 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
||||
);
|
||||
if is_embeddable {
|
||||
let alt = if is_dimension_spec(alt_param) { "" } else { alt_param };
|
||||
let alt = if is_dimension_spec(alt_param) {
|
||||
""
|
||||
} else {
|
||||
alt_param
|
||||
};
|
||||
result.links_converted += 1;
|
||||
format!("", alt, link_target)
|
||||
} else {
|
||||
let display = if alt_param.is_empty() {
|
||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
||||
} else if is_dimension_spec(alt_param) {
|
||||
let display = if alt_param.is_empty() || is_dimension_spec(alt_param) {
|
||||
file_part.rsplit('/').next().unwrap_or(file_part)
|
||||
} else {
|
||||
alt_param
|
||||
@@ -137,7 +139,11 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
let (file_part, anchor) = split_anchor(note_ref);
|
||||
if file_part.is_empty() {
|
||||
if let Some(a) = anchor {
|
||||
let display = if display_param.is_empty() { a } else { display_param };
|
||||
let display = if display_param.is_empty() {
|
||||
a
|
||||
} else {
|
||||
display_param
|
||||
};
|
||||
result.links_converted += 1;
|
||||
return format!("[{}](#{})", display, a);
|
||||
}
|
||||
@@ -165,8 +171,22 @@ pub fn import(vault_path: &str) -> Result<ImportResult, String> {
|
||||
.to_string();
|
||||
content = after_links;
|
||||
|
||||
content = fix_md_image_refs(&content, &md_img_re, vault, note_dir, &file_index, &mut result.links_converted);
|
||||
content = fix_md_link_refs(&content, &md_link_re, vault, note_dir, &file_index, &mut result.links_converted);
|
||||
content = fix_md_image_refs(
|
||||
&content,
|
||||
&md_img_re,
|
||||
vault,
|
||||
note_dir,
|
||||
&file_index,
|
||||
&mut result.links_converted,
|
||||
);
|
||||
content = fix_md_link_refs(
|
||||
&content,
|
||||
&md_link_re,
|
||||
vault,
|
||||
note_dir,
|
||||
&file_index,
|
||||
&mut result.links_converted,
|
||||
);
|
||||
|
||||
if result.links_converted > links_before {
|
||||
changed = true;
|
||||
@@ -267,7 +287,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
let tags = normalize_tags(&mapping);
|
||||
|
||||
let title = mapping
|
||||
.get(&serde_yaml::Value::String("title".into()))
|
||||
.get(serde_yaml::Value::String("title".into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
@@ -283,7 +303,7 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
});
|
||||
|
||||
let id = mapping
|
||||
.get(&serde_yaml::Value::String("id".into()))
|
||||
.get(serde_yaml::Value::String("id".into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -293,9 +313,9 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
.iter()
|
||||
.find_map(|key| {
|
||||
mapping
|
||||
.get(&serde_yaml::Value::String((*key).into()))
|
||||
.get(serde_yaml::Value::String((*key).into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
||||
.and_then(frontmatter::parse_date_flexible)
|
||||
})
|
||||
.or_else(|| file_created(path))
|
||||
.unwrap_or_else(Utc::now);
|
||||
@@ -304,15 +324,15 @@ fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) {
|
||||
.iter()
|
||||
.find_map(|key| {
|
||||
mapping
|
||||
.get(&serde_yaml::Value::String((*key).into()))
|
||||
.get(serde_yaml::Value::String((*key).into()))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| frontmatter::parse_date_flexible(s))
|
||||
.and_then(frontmatter::parse_date_flexible)
|
||||
})
|
||||
.or_else(|| file_modified(path))
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
let pinned = mapping
|
||||
.get(&serde_yaml::Value::String("pinned".into()))
|
||||
.get(serde_yaml::Value::String("pinned".into()))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -332,7 +352,7 @@ fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for key in &["tags", "tag"] {
|
||||
if let Some(val) = mapping.get(&serde_yaml::Value::String((*key).into())) {
|
||||
if let Some(val) = mapping.get(serde_yaml::Value::String((*key).into())) {
|
||||
for raw in yaml_value_to_strings(val) {
|
||||
let cleaned = raw.trim().trim_start_matches('#').trim().to_string();
|
||||
if !cleaned.is_empty() && seen.insert(cleaned.to_lowercase()) {
|
||||
@@ -389,9 +409,7 @@ fn file_created(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
.ok()
|
||||
.and_then(|m| m.created().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| {
|
||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
||||
})
|
||||
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||
}
|
||||
|
||||
fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
@@ -399,9 +417,7 @@ fn file_modified(path: &Path) -> Option<chrono::DateTime<Utc>> {
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|d| {
|
||||
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
|
||||
})
|
||||
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
|
||||
}
|
||||
|
||||
fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String {
|
||||
@@ -513,7 +529,9 @@ fn fix_md_link_refs(
|
||||
let display = &caps[1];
|
||||
let href = &caps[2];
|
||||
let decoded = percent_decode(href);
|
||||
if decoded.starts_with("http") || decoded.starts_with('/') || decoded.starts_with("data:")
|
||||
if decoded.starts_with("http")
|
||||
|| decoded.starts_with('/')
|
||||
|| decoded.starts_with("data:")
|
||||
|| decoded.starts_with('#')
|
||||
{
|
||||
return format!("[{}]({})", display, href);
|
||||
@@ -549,9 +567,7 @@ fn fix_md_link_refs(
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn move_attachments(
|
||||
vault: &Path,
|
||||
) -> Result<HashMap<String, String>, String> {
|
||||
fn move_attachments(vault: &Path) -> Result<HashMap<String, String>, String> {
|
||||
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
||||
let _ = std::fs::create_dir_all(&attachments_dir);
|
||||
|
||||
@@ -601,10 +617,7 @@ fn move_attachments(
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn rewrite_attachment_refs(
|
||||
vault: &Path,
|
||||
moved: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
fn rewrite_attachment_refs(vault: &Path, moved: &HashMap<String, String>) -> Result<(), String> {
|
||||
let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
||||
|
||||
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
||||
@@ -686,7 +699,7 @@ fn cleanup_empty_dirs(root: &Path) {
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.collect();
|
||||
|
||||
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
|
||||
dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
|
||||
|
||||
for dir in dirs {
|
||||
let dir_str = dir.to_string_lossy();
|
||||
@@ -719,7 +732,8 @@ fn is_dimension_spec(s: &str) -> bool {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
s.chars().all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
|
||||
s.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == 'x' || c == 'X')
|
||||
}
|
||||
|
||||
fn resolve_wiki_ref(file_index: &HashMap<String, String>, reference: &str) -> String {
|
||||
@@ -907,7 +921,10 @@ mod tests {
|
||||
extract_heading_title("# My Title\n\nBody"),
|
||||
Some("My Title".to_string())
|
||||
);
|
||||
assert_eq!(extract_heading_title("\n\n# Spaced Title"), Some("Spaced Title".to_string()));
|
||||
assert_eq!(
|
||||
extract_heading_title("\n\n# Spaced Title"),
|
||||
Some("Spaced Title".to_string())
|
||||
);
|
||||
assert_eq!(extract_heading_title("Body without heading"), None);
|
||||
assert_eq!(extract_heading_title("## Subheading"), None);
|
||||
assert_eq!(extract_heading_title(""), None);
|
||||
@@ -947,8 +964,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_normalize_tags_strips_hash() {
|
||||
let mapping: serde_yaml::Mapping =
|
||||
serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
||||
let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap();
|
||||
let tags = normalize_tags(&mapping);
|
||||
assert_eq!(tags, vec!["hashed"]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,107 @@ pub fn helixnotes_dir(vault_path: &str) -> PathBuf {
|
||||
Path::new(vault_path).join(".helixnotes")
|
||||
}
|
||||
|
||||
fn canonicalize_path(path: &Path, label: &str) -> Result<PathBuf, String> {
|
||||
fs::canonicalize(path).map_err(|error| format!("Invalid {label}: {error}"))
|
||||
}
|
||||
|
||||
fn ensure_vault_content_path(
|
||||
vault_path: &str,
|
||||
requested_path: &Path,
|
||||
allow_root: bool,
|
||||
) -> Result<PathBuf, String> {
|
||||
let vault = canonicalize_path(Path::new(vault_path), "vault path")?;
|
||||
let requested = canonicalize_path(requested_path, "vault item path")?;
|
||||
let metadata = vault.join(".helixnotes");
|
||||
|
||||
if !requested.starts_with(&vault)
|
||||
|| requested.starts_with(&metadata)
|
||||
|| (!allow_root && requested == vault)
|
||||
{
|
||||
return Err("Path must stay inside the active vault".to_string());
|
||||
}
|
||||
|
||||
Ok(requested_path.to_path_buf())
|
||||
}
|
||||
|
||||
fn ensure_vault_content_dir(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||
let requested = ensure_vault_content_path(vault_path, requested_path, true)?;
|
||||
if !requested.is_dir() {
|
||||
return Err("Vault destination is not a directory".to_string());
|
||||
}
|
||||
Ok(requested)
|
||||
}
|
||||
|
||||
fn ensure_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
|
||||
if !requested.is_file()
|
||||
|| requested
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
!= Some("md")
|
||||
{
|
||||
return Err("Note path must point to a Markdown file".to_string());
|
||||
}
|
||||
Ok(requested)
|
||||
}
|
||||
|
||||
fn ensure_readable_note_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||
if let Ok(note) = ensure_note_path(vault_path, requested_path) {
|
||||
return Ok(note);
|
||||
}
|
||||
|
||||
let trashed_note = ensure_trash_entry(vault_path, requested_path)?;
|
||||
if !trashed_note.is_file()
|
||||
|| trashed_note
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
!= Some("md")
|
||||
{
|
||||
return Err("Note path must point to a Markdown file".to_string());
|
||||
}
|
||||
Ok(trashed_note)
|
||||
}
|
||||
|
||||
fn ensure_notebook_path(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||
let requested = ensure_vault_content_path(vault_path, requested_path, false)?;
|
||||
if !requested.is_dir() {
|
||||
return Err("Notebook path must point to a directory".to_string());
|
||||
}
|
||||
Ok(requested)
|
||||
}
|
||||
|
||||
fn ensure_trash_entry(vault_path: &str, requested_path: &Path) -> Result<PathBuf, String> {
|
||||
let trash = canonicalize_path(&helixnotes_dir(vault_path).join("trash"), "trash path")?;
|
||||
let requested = canonicalize_path(requested_path, "trash item path")?;
|
||||
if requested == trash || !requested.starts_with(&trash) {
|
||||
return Err("Path must be an item inside the active vault trash".to_string());
|
||||
}
|
||||
Ok(requested_path.to_path_buf())
|
||||
}
|
||||
|
||||
fn safe_relative_path(path: &str) -> Result<&Path, String> {
|
||||
let relative = Path::new(path);
|
||||
if relative.as_os_str().is_empty()
|
||||
|| !relative
|
||||
.components()
|
||||
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
return Err("Path must be a safe vault-relative path".to_string());
|
||||
}
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
fn safe_child_name(name: &str) -> Result<&str, String> {
|
||||
let mut components = Path::new(name).components();
|
||||
if name.trim().is_empty()
|
||||
|| !matches!(components.next(), Some(Component::Normal(_)))
|
||||
|| components.next().is_some()
|
||||
{
|
||||
return Err("Name must not contain path separators".to_string());
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
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())?;
|
||||
@@ -140,7 +241,11 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
paths
|
||||
.par_iter()
|
||||
.map(|path| {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
@@ -191,7 +296,11 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
||||
let entries: Vec<NotebookEntry> = paths
|
||||
.par_iter()
|
||||
.map(|path| {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
@@ -243,9 +352,15 @@ pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
|
||||
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);
|
||||
ensure_vault_content_dir(vault_path, root)?;
|
||||
let vault_root = Path::new(vault_path);
|
||||
|
||||
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists());
|
||||
log::info!(
|
||||
"scan_notes: vault={}, scan={}, exists={}",
|
||||
vault_path,
|
||||
scan_path,
|
||||
root.exists()
|
||||
);
|
||||
|
||||
if !root.exists() {
|
||||
return Err("Path does not exist".to_string());
|
||||
@@ -301,7 +416,9 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
||||
} else {
|
||||
WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
|
||||
.filter_entry(|e| {
|
||||
!is_hidden(e.path()) && !e.path().starts_with(helixnotes_dir(vault_path))
|
||||
})
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
@@ -313,7 +430,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
||||
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
|
||||
.collect();
|
||||
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||
Ok(notes)
|
||||
}
|
||||
}
|
||||
@@ -423,8 +540,18 @@ fn read_note_entry_from_str(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
||||
let p = Path::new(path);
|
||||
pub fn read_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
|
||||
let validated = ensure_readable_note_path(vault_path, Path::new(path))?;
|
||||
read_note_content(&validated, path)
|
||||
}
|
||||
|
||||
pub fn read_vault_note(vault_path: &str, path: &str) -> Result<NoteContent, String> {
|
||||
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||
read_note_content(&validated, path)
|
||||
}
|
||||
|
||||
fn read_note_content(validated: &Path, reported_path: &str) -> Result<NoteContent, String> {
|
||||
let p = validated;
|
||||
let raw = fs::read_to_string(p).map_err(|e| e.to_string())?;
|
||||
let filename = p
|
||||
.file_name()
|
||||
@@ -454,14 +581,15 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
||||
}
|
||||
|
||||
Ok(NoteContent {
|
||||
path: path.to_string(),
|
||||
path: reported_path.to_string(),
|
||||
meta,
|
||||
content,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
|
||||
pub fn save_note(vault_path: &str, path: &str, meta: &NoteMeta, body: &str) -> Result<(), String> {
|
||||
let path = ensure_note_path(vault_path, Path::new(path))?;
|
||||
let mut updated_meta = meta.clone();
|
||||
updated_meta.modified = Utc::now();
|
||||
|
||||
@@ -471,7 +599,7 @@ pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String>
|
||||
}
|
||||
|
||||
// Read existing file to preserve unknown frontmatter fields
|
||||
let existing = fs::read_to_string(path).unwrap_or_default();
|
||||
let existing = fs::read_to_string(&path).unwrap_or_default();
|
||||
let raw = if existing.is_empty() {
|
||||
frontmatter::update_note_raw(&updated_meta, body)
|
||||
} else {
|
||||
@@ -487,14 +615,11 @@ pub fn create_note(
|
||||
notebook_relative: Option<&str>,
|
||||
title: &str,
|
||||
) -> Result<NoteEntry, String> {
|
||||
let dir = match notebook_relative {
|
||||
Some(rel) => Path::new(vault_path).join(rel),
|
||||
let requested_dir = match notebook_relative {
|
||||
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
|
||||
None => PathBuf::from(vault_path),
|
||||
};
|
||||
|
||||
if !dir.exists() {
|
||||
return Err("Notebook directory does not exist".to_string());
|
||||
}
|
||||
let dir = ensure_vault_content_dir(vault_path, &requested_dir)?;
|
||||
|
||||
let filename = sanitize_filename(title);
|
||||
let mut file_path = dir.join(format!("{}.md", filename));
|
||||
@@ -535,10 +660,8 @@ pub fn create_note(
|
||||
}
|
||||
|
||||
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.is_file() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||
let src = validated.as_path();
|
||||
|
||||
let parent = src
|
||||
.parent()
|
||||
@@ -672,7 +795,9 @@ pub fn create_daily_note(
|
||||
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
||||
_ => {
|
||||
let locale = get_system_locale();
|
||||
target_date.format_localized("%B %d, %Y", locale).to_string()
|
||||
target_date
|
||||
.format_localized("%B %d, %Y", locale)
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -719,12 +844,12 @@ pub fn create_notebook(
|
||||
parent_relative: Option<&str>,
|
||||
name: &str,
|
||||
) -> Result<NotebookEntry, String> {
|
||||
let parent = match parent_relative {
|
||||
Some(rel) => Path::new(vault_path).join(rel),
|
||||
let requested_parent = match parent_relative {
|
||||
Some(rel) => Path::new(vault_path).join(safe_relative_path(rel)?),
|
||||
None => PathBuf::from(vault_path),
|
||||
};
|
||||
|
||||
let dir_path = parent.join(name);
|
||||
let parent = ensure_vault_content_dir(vault_path, &requested_parent)?;
|
||||
let dir_path = parent.join(safe_child_name(name)?);
|
||||
if dir_path.exists() {
|
||||
return Err("Notebook already exists".to_string());
|
||||
}
|
||||
@@ -748,10 +873,8 @@ pub fn create_notebook(
|
||||
}
|
||||
|
||||
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 validated = ensure_note_path(vault_path, Path::new(note_path))?;
|
||||
let src = validated.as_path();
|
||||
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
@@ -771,10 +894,8 @@ pub fn delete_note(vault_path: &str, note_path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
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 validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
|
||||
let src = validated.as_path();
|
||||
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
@@ -803,10 +924,8 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
|
||||
}
|
||||
|
||||
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.exists() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
let validated = ensure_note_path(vault_path, Path::new(path))?;
|
||||
let src = validated.as_path();
|
||||
|
||||
// Read old title before renaming
|
||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||
@@ -839,7 +958,13 @@ pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<Stri
|
||||
let new_path_str = new_path.to_string_lossy().to_string();
|
||||
|
||||
// Update wikilinks in other notes that reference this note
|
||||
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
|
||||
update_wikilinks_after_rename(
|
||||
vault_path,
|
||||
&old_path_str,
|
||||
&new_path_str,
|
||||
&old_title,
|
||||
new_title,
|
||||
);
|
||||
|
||||
Ok(new_path_str)
|
||||
}
|
||||
@@ -898,11 +1023,17 @@ fn update_wikilinks_after_rename(
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() { continue; }
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let path_str = path.to_string_lossy();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
// Skip the renamed note itself
|
||||
if *path_str == *new_path { continue; }
|
||||
if *path_str == *new_path {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
@@ -918,22 +1049,13 @@ fn update_wikilinks_after_rename(
|
||||
// If another note shares the same title, these would be ambiguous.
|
||||
if old_title != new_title && title_is_unique {
|
||||
// 1. Short title ref: [[Old Title]] → [[New Title]]
|
||||
result = result.replace(
|
||||
&format!("[[{}]]", old_title),
|
||||
&format!("[[{}]]", new_title),
|
||||
);
|
||||
result = result.replace(&format!("[[{}]]", old_title), &format!("[[{}]]", new_title));
|
||||
|
||||
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
|
||||
result = result.replace(
|
||||
&format!("[[{}|", old_title),
|
||||
&format!("[[{}|", new_title),
|
||||
);
|
||||
result = result.replace(&format!("[[{}|", old_title), &format!("[[{}|", new_title));
|
||||
|
||||
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
|
||||
result = result.replace(
|
||||
&format!("|{}]]", old_title),
|
||||
&format!("|{}]]", new_title),
|
||||
);
|
||||
result = result.replace(&format!("|{}]]", old_title), &format!("|{}]]", new_title));
|
||||
}
|
||||
|
||||
// Path-based rules are always safe (paths are unique).
|
||||
@@ -966,13 +1088,11 @@ fn update_wikilinks_after_rename(
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
pub fn rename_notebook(vault_path: &str, path: &str, new_name: &str) -> Result<String, String> {
|
||||
let validated = ensure_notebook_path(vault_path, Path::new(path))?;
|
||||
let src = validated.as_path();
|
||||
|
||||
let new_path = src.parent().unwrap().join(new_name);
|
||||
let new_path = src.parent().unwrap().join(safe_child_name(new_name)?);
|
||||
if new_path.exists() {
|
||||
return Err("A notebook with that name already exists".to_string());
|
||||
}
|
||||
@@ -981,13 +1101,12 @@ pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, 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());
|
||||
}
|
||||
pub fn move_note(vault_path: &str, note_path: &str, dest_notebook: &str) -> Result<String, String> {
|
||||
let validated = ensure_note_path(vault_path, Path::new(note_path))?;
|
||||
let src = validated.as_path();
|
||||
|
||||
let dest_dir = Path::new(dest_notebook);
|
||||
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_notebook))?;
|
||||
let dest_dir = validated_dest.as_path();
|
||||
if !dest_dir.is_dir() {
|
||||
return Err("Destination notebook does not exist".to_string());
|
||||
}
|
||||
@@ -999,13 +1118,16 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String>
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
|
||||
let src = Path::new(notebook_path);
|
||||
if !src.exists() || !src.is_dir() {
|
||||
return Err("Notebook does not exist".to_string());
|
||||
}
|
||||
pub fn move_notebook(
|
||||
vault_path: &str,
|
||||
notebook_path: &str,
|
||||
dest_parent: &str,
|
||||
) -> Result<String, String> {
|
||||
let validated = ensure_notebook_path(vault_path, Path::new(notebook_path))?;
|
||||
let src = validated.as_path();
|
||||
|
||||
let dest_parent_path = Path::new(dest_parent);
|
||||
let validated_dest = ensure_vault_content_dir(vault_path, Path::new(dest_parent))?;
|
||||
let dest_parent_path = validated_dest.as_path();
|
||||
if !dest_parent_path.is_dir() {
|
||||
return Err("Destination does not exist".to_string());
|
||||
}
|
||||
@@ -1048,7 +1170,10 @@ fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
|
||||
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
if !trash_dir.exists() {
|
||||
return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
|
||||
return Ok(TrashContents {
|
||||
notes: Vec::new(),
|
||||
notebooks: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let vault_root = Path::new(vault_path);
|
||||
@@ -1067,7 +1192,10 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
.min_depth(1)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.filter(|e| {
|
||||
e.path().is_file()
|
||||
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
||||
})
|
||||
.count();
|
||||
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
// Strip timestamp prefix to get original notebook name
|
||||
@@ -1080,7 +1208,7 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
};
|
||||
let modified = fs::metadata(&path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| DateTime::<Utc>::from(t))
|
||||
.map(DateTime::<Utc>::from)
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
notebooks.push(TrashNotebookEntry {
|
||||
name,
|
||||
@@ -1091,8 +1219,8 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
}
|
||||
}
|
||||
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||
notes.sort_by_key(|note| std::cmp::Reverse(note.meta.modified));
|
||||
notebooks.sort_by_key(|notebook| std::cmp::Reverse(notebook.modified));
|
||||
Ok(TrashContents { notes, notebooks })
|
||||
}
|
||||
|
||||
@@ -1101,15 +1229,17 @@ pub fn restore_note(
|
||||
trash_path: &str,
|
||||
dest_notebook: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let src = Path::new(trash_path);
|
||||
if !src.exists() {
|
||||
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
|
||||
let src = validated.as_path();
|
||||
if !src.is_file() {
|
||||
return Err("Trashed note does not exist".to_string());
|
||||
}
|
||||
|
||||
let dest_dir = match dest_notebook {
|
||||
let requested_dest = match dest_notebook {
|
||||
Some(nb) => PathBuf::from(nb),
|
||||
None => PathBuf::from(vault_path),
|
||||
};
|
||||
let dest_dir = ensure_vault_content_dir(vault_path, &requested_dest)?;
|
||||
|
||||
// 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();
|
||||
@@ -1132,15 +1262,18 @@ pub fn restore_note(
|
||||
}
|
||||
|
||||
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
|
||||
let src = Path::new(trash_path);
|
||||
if !src.exists() || !src.is_dir() {
|
||||
let validated = ensure_trash_entry(vault_path, Path::new(trash_path))?;
|
||||
let src = validated.as_path();
|
||||
if !src.is_dir() {
|
||||
return Err("Trashed notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Try to read original path from sidecar .meta file
|
||||
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
|
||||
let meta_path = src
|
||||
.with_extension("")
|
||||
.with_file_name(format!("{}.meta", dirname));
|
||||
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
|
||||
original
|
||||
} else {
|
||||
@@ -1156,7 +1289,7 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let dest = Path::new(vault_path).join(&relative);
|
||||
let dest = Path::new(vault_path).join(safe_relative_path(&relative)?);
|
||||
|
||||
// Recreate parent directories if needed
|
||||
if let Some(parent) = dest.parent() {
|
||||
@@ -1170,7 +1303,8 @@ pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, St
|
||||
}
|
||||
|
||||
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
|
||||
let p = Path::new(path);
|
||||
let validated = ensure_trash_entry(vault_path, Path::new(path))?;
|
||||
let p = validated.as_path();
|
||||
let parent = p.parent().map(|pp| pp.to_path_buf());
|
||||
if p.is_dir() {
|
||||
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
||||
@@ -1431,8 +1565,9 @@ pub fn sanitize_filename(name: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
compare_natural_names, duplicate_note, get_note_switcher_titles, helixnotes_dir,
|
||||
load_notebook_icons, scan_notebooks, set_notebook_icon,
|
||||
compare_natural_names, create_notebook, duplicate_note, get_note_switcher_titles,
|
||||
helixnotes_dir, load_notebook_icons, permanent_delete, read_note, restore_notebook,
|
||||
scan_notebooks, set_notebook_icon,
|
||||
};
|
||||
use std::fs;
|
||||
use uuid::Uuid;
|
||||
@@ -1441,10 +1576,7 @@ mod tests {
|
||||
fn compares_numeric_segments_anywhere_in_names() {
|
||||
let mut names = ["Class 10b", "Class 2b", "Class 10a", "Class 2a"];
|
||||
names.sort_by(|left, right| compare_natural_names(left, right));
|
||||
assert_eq!(
|
||||
names,
|
||||
["Class 2a", "Class 2b", "Class 10a", "Class 10b"]
|
||||
);
|
||||
assert_eq!(names, ["Class 2a", "Class 2b", "Class 10a", "Class 10b"]);
|
||||
assert_eq!(
|
||||
compare_natural_names("Class 02", "Class 2b"),
|
||||
std::cmp::Ordering::Less
|
||||
@@ -1592,6 +1724,75 @@ mod tests {
|
||||
fs::remove_file(outside).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_markdown_notes_from_trash_without_allowing_external_files() {
|
||||
let test_root =
|
||||
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||
let vault = test_root.join("vault");
|
||||
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
|
||||
let trashed_note = trash.join("20240101000000000_Note.md");
|
||||
let outside = test_root.join("outside.md");
|
||||
fs::create_dir_all(&trash).unwrap();
|
||||
fs::write(&trashed_note, "---\ntitle: Note\n---\n\ntrashed").unwrap();
|
||||
fs::write(&outside, "outside").unwrap();
|
||||
|
||||
assert!(read_note(&vault.to_string_lossy(), &trashed_note.to_string_lossy()).is_ok());
|
||||
assert!(read_note(&vault.to_string_lossy(), &outside.to_string_lossy()).is_err());
|
||||
|
||||
fs::remove_dir_all(test_root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_permanent_deletion_outside_trash() {
|
||||
let test_root =
|
||||
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||
let vault = test_root.join("vault");
|
||||
let outside = test_root.join("outside.md");
|
||||
fs::create_dir_all(helixnotes_dir(&vault.to_string_lossy()).join("trash")).unwrap();
|
||||
fs::write(&outside, "must survive").unwrap();
|
||||
|
||||
let result = permanent_delete(&vault.to_string_lossy(), &outside.to_string_lossy());
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read_to_string(&outside).unwrap(), "must survive");
|
||||
fs::remove_dir_all(test_root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_notebook_creation_outside_vault() {
|
||||
let test_root =
|
||||
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||
let vault = test_root.join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
let result = create_notebook(&vault.to_string_lossy(), Some(".."), "escaped");
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(!test_root.join("escaped").exists());
|
||||
fs::remove_dir_all(test_root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_in_restored_notebook_metadata() {
|
||||
let test_root =
|
||||
std::env::temp_dir().join(format!("helixnotes-path-security-test-{}", Uuid::new_v4()));
|
||||
let vault = test_root.join("vault");
|
||||
let trash = helixnotes_dir(&vault.to_string_lossy()).join("trash");
|
||||
let trashed_notebook = trash.join("20240101000000000_Notebook");
|
||||
fs::create_dir_all(&trashed_notebook).unwrap();
|
||||
fs::write(trash.join("20240101000000000_Notebook.meta"), "../escaped").unwrap();
|
||||
|
||||
let result = restore_notebook(
|
||||
&vault.to_string_lossy(),
|
||||
&trashed_notebook.to_string_lossy(),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(trashed_notebook.exists());
|
||||
assert!(!test_root.join("escaped").exists());
|
||||
fs::remove_dir_all(test_root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||
let vault =
|
||||
@@ -1623,4 +1824,4 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(vault).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user