mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
2011 lines
72 KiB
Rust
2011 lines
72 KiB
Rust
use crate::search::SearchIndex;
|
|
use crate::state::AppState;
|
|
use crate::types::*;
|
|
use crate::vault::{operations, watcher};
|
|
use std::path::Path;
|
|
use tauri::{AppHandle, Manager, State};
|
|
|
|
// ── Vault Management ──
|
|
|
|
#[tauri::command]
|
|
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
|
|
operations::ensure_vault_structure(&path)?;
|
|
|
|
// Initialize search index - rebuild in background on mobile (sandboxed FS is slow)
|
|
let search = std::sync::Arc::new(SearchIndex::new(&path)?);
|
|
#[cfg(mobile)]
|
|
{
|
|
let search_bg = search.clone();
|
|
let vault = path.clone();
|
|
std::thread::spawn(move || {
|
|
let _ = search_bg.rebuild(&vault);
|
|
log::info!("mobile: search index rebuild complete");
|
|
});
|
|
}
|
|
#[cfg(desktop)]
|
|
{
|
|
search.rebuild(&path)?;
|
|
}
|
|
*state.search_index.lock().map_err(|e| e.to_string())? = Some(search);
|
|
|
|
// Start file watcher
|
|
let w = watcher::start_watcher(app.clone(), path.clone())?;
|
|
*state.watcher.lock().map_err(|e| e.to_string())? = Some(w);
|
|
|
|
// Update config
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
if !config.vaults.iter().any(|v| v.path == path) {
|
|
let name = std::path::Path::new(&path)
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
config.vaults.push(VaultConfig {
|
|
path: path.clone(),
|
|
name,
|
|
});
|
|
}
|
|
config.active_vault = Some(path);
|
|
save_app_config(&config)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_app_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
|
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
|
Ok(config.clone())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_pending_open_file(state: State<'_, AppState>) -> Result<Option<String>, String> {
|
|
let mut pending = state.pending_open_file.lock().map_err(|e| e.to_string())?;
|
|
Ok(pending.take())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_theme(state: State<'_, AppState>, theme: String) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
config.theme = theme;
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_accent_color(state: State<'_, AppState>, color: String) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
config.accent_color = Some(color);
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_font_size(state: State<'_, AppState>, size: u32) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
config.font_size = Some(size);
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_font_family(state: State<'_, AppState>, family: String) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
config.font_family = Some(family);
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
config.line_height = Some(height);
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_ui_scale(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)?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Notebooks ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_notebooks(state: State<'_, AppState>) -> Result<Vec<NotebookEntry>, 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")?;
|
|
operations::scan_notebooks(vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn count_root_notes(state: State<'_, AppState>) -> Result<usize, 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")?;
|
|
operations::count_root_notes(vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_notebook(
|
|
state: State<'_, AppState>,
|
|
parent_relative: Option<String>,
|
|
name: String,
|
|
) -> Result<NotebookEntry, 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")?;
|
|
operations::create_notebook(vault_path, parent_relative.as_deref(), &name)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn rename_notebook(path: String, new_name: String) -> Result<String, String> {
|
|
operations::rename_notebook(&path, &new_name)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn move_notebook(
|
|
state: State<'_, AppState>,
|
|
notebook_path: String,
|
|
dest_parent: 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")?;
|
|
|
|
let old_relative = Path::new(¬ebook_path)
|
|
.strip_prefix(vault_path.as_str())
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
|
|
let new_full_path = operations::move_notebook(¬ebook_path, &dest_parent)?;
|
|
|
|
let new_relative = Path::new(&new_full_path)
|
|
.strip_prefix(vault_path.as_str())
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
|
|
// Update Quick Access paths for all notes inside the moved notebook
|
|
if let Ok(mut qa) = operations::load_quick_access(vault_path) {
|
|
let old_prefix = format!("{}/", old_relative);
|
|
let mut changed = false;
|
|
for path in qa.iter_mut() {
|
|
if path.starts_with(&old_prefix) {
|
|
*path = format!("{}/{}", new_relative, &path[old_prefix.len()..]);
|
|
changed = true;
|
|
}
|
|
}
|
|
if changed {
|
|
let _ = operations::save_quick_access(vault_path, &qa);
|
|
}
|
|
}
|
|
|
|
// Update notebook icon mappings
|
|
if let Ok(icons) = operations::load_notebook_icons(vault_path) {
|
|
let old_prefix = format!("{}/", old_relative);
|
|
let mut new_icons = std::collections::HashMap::new();
|
|
let mut changed = false;
|
|
for (key, value) in &icons {
|
|
if *key == old_relative {
|
|
new_icons.insert(new_relative.clone(), value.clone());
|
|
changed = true;
|
|
} else if key.starts_with(&old_prefix) {
|
|
let new_key = format!("{}/{}", new_relative, &key[old_prefix.len()..]);
|
|
new_icons.insert(new_key, value.clone());
|
|
changed = true;
|
|
} else {
|
|
new_icons.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
if changed {
|
|
let icons_path = operations::helixnotes_dir(vault_path).join("notebook_icons.json");
|
|
if let Ok(data) = serde_json::to_string_pretty(&new_icons) {
|
|
let _ = std::fs::write(&icons_path, data);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rebuild search index (paths changed for all contained notes)
|
|
if let Ok(search_guard) = state.search_index.lock() {
|
|
if let Some(ref search) = *search_guard {
|
|
let _ = search.rebuild(vault_path);
|
|
}
|
|
}
|
|
|
|
Ok(new_full_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
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()
|
|
};
|
|
operations::delete_notebook(&vault_path, &path)
|
|
}
|
|
|
|
// ── Notes ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_notes(
|
|
state: State<'_, AppState>,
|
|
notebook_path: Option<String>,
|
|
) -> Result<Vec<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")?;
|
|
operations::scan_notes(vault_path, notebook_path.as_deref())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn read_note(path: String) -> Result<NoteContent, String> {
|
|
operations::read_note(&path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn save_note(
|
|
state: State<'_, AppState>,
|
|
path: String,
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
drop(config);
|
|
|
|
operations::save_note(&path, &meta, &body)?;
|
|
|
|
// Re-index note so search picks up changes immediately
|
|
if let Ok(search_guard) = state.search_index.lock() {
|
|
if let Some(ref search) = *search_guard {
|
|
let _ = search.index_note(&path);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_note(
|
|
state: State<'_, AppState>,
|
|
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
|
|
if let Ok(search_guard) = state.search_index.lock() {
|
|
if let Some(ref search) = *search_guard {
|
|
let _ = search.index_note(&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())?;
|
|
|
|
if let Ok(search_guard) = state.search_index.lock() {
|
|
if let Some(ref search) = *search_guard {
|
|
let _ = search.index_note(&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)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), 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")?;
|
|
operations::delete_note(vault_path, &path)?;
|
|
|
|
// Remove from index
|
|
if let Ok(search_guard) = state.search_index.lock() {
|
|
if let Some(ref search) = *search_guard {
|
|
let _ = search.remove_note(&path);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn move_note(
|
|
state: State<'_, AppState>,
|
|
note_path: String,
|
|
dest_notebook: 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")?;
|
|
|
|
// Compute old relative path before move
|
|
let old_relative = Path::new(¬e_path)
|
|
.strip_prefix(vault_path)
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
|
|
let new_full_path = operations::move_note(¬e_path, &dest_notebook)?;
|
|
|
|
// Update quick access if the moved note was in it
|
|
if !old_relative.is_empty() {
|
|
if let Ok(mut qa) = operations::load_quick_access(vault_path) {
|
|
if let Some(pos) = qa.iter().position(|p| *p == old_relative) {
|
|
let new_relative = Path::new(&new_full_path)
|
|
.strip_prefix(vault_path)
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
if !new_relative.is_empty() {
|
|
qa[pos] = new_relative;
|
|
let _ = operations::save_quick_access(vault_path, &qa);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(new_full_path)
|
|
}
|
|
|
|
// ── Tags ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_all_tags(state: State<'_, AppState>) -> Result<Vec<(String, usize)>, 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")?;
|
|
operations::get_all_tags(vault_path)
|
|
}
|
|
|
|
// ── Wiki-links ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEntry>, 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 vault = std::path::Path::new(vault_path);
|
|
let hn_dir = operations::helixnotes_dir(vault_path);
|
|
let mut entries = Vec::new();
|
|
|
|
for entry in walkdir::WalkDir::new(vault)
|
|
.into_iter()
|
|
// Cross-platform exclusion (string "/.helixnotes/" checks miss Windows
|
|
// backslash paths, which leaked .helixnotes/history snapshots in as dupes).
|
|
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
|
.filter_map(|e| e.ok())
|
|
{
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
let path_str = path.to_string_lossy();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
continue;
|
|
}
|
|
// Read frontmatter title, fallback to filename
|
|
let title = if let Ok(raw) = std::fs::read_to_string(path) {
|
|
crate::vault::frontmatter::extract_title(&raw).unwrap_or_else(|| {
|
|
path.file_stem()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string()
|
|
})
|
|
} else {
|
|
path.file_stem()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string()
|
|
};
|
|
|
|
entries.push(NoteTitleEntry {
|
|
title,
|
|
path: path_str.to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
// ── Graph ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphData, String> {
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
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 vault = std::path::Path::new(vault_path);
|
|
|
|
// Pass 1: collect ALL notes as nodes (no title deduplication - every note must appear)
|
|
let mut graph_nodes = Vec::new();
|
|
// title → list of node indices (one title can map to multiple notes in different folders)
|
|
let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
|
|
// vault-relative path without extension → idx (for [[subfolder/note]] style links)
|
|
let mut relpath_to_idx: HashMap<String, usize> = HashMap::new();
|
|
// absolute path → idx (for fast active-note lookup)
|
|
let mut path_to_idx: HashMap<String, usize> = HashMap::new();
|
|
let mut seen_paths: HashSet<String> = HashSet::new();
|
|
let mut contents: Vec<String> = Vec::new();
|
|
|
|
let hn_dir = operations::helixnotes_dir(vault_path);
|
|
for entry in walkdir::WalkDir::new(vault)
|
|
.into_iter()
|
|
// Cross-platform exclusion (string "/.helixnotes/" checks miss Windows
|
|
// backslash paths; is_hidden also covers .stversions/.stfolder/.trash/.git).
|
|
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
|
.filter_map(|e| e.ok())
|
|
{
|
|
let path = entry.path();
|
|
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; }
|
|
// Skip Syncthing conflict files
|
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
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; }
|
|
|
|
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()
|
|
});
|
|
|
|
let idx = graph_nodes.len();
|
|
let title_lower = title.to_lowercase();
|
|
title_to_idxs.entry(title_lower).or_default().push(idx);
|
|
path_to_idx.insert(path_str.clone(), idx);
|
|
|
|
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
|
|
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();
|
|
relpath_to_idx.entry(rel_lower).or_insert(idx);
|
|
}
|
|
|
|
graph_nodes.push(crate::types::GraphNode {
|
|
title,
|
|
path: path_str,
|
|
});
|
|
contents.push(raw);
|
|
}
|
|
|
|
// Pass 2: extract edges from wiki-links (inline scan, no regex)
|
|
// edge_map: (src, tgt) → index in edges vec, used to detect reverse links
|
|
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
|
|
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 });
|
|
}
|
|
};
|
|
|
|
for (source_idx, body) in contents.iter().enumerate() {
|
|
let bytes = body.as_bytes();
|
|
let len = bytes.len();
|
|
let mut i = 0;
|
|
while i + 1 < len {
|
|
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
|
i += 2;
|
|
let start = i;
|
|
while i + 1 < len && !(bytes[i] == b']' && bytes[i + 1] == b']') {
|
|
i += 1;
|
|
}
|
|
if i + 1 < len {
|
|
let link_raw = &body[start..i];
|
|
// Strip |alias, #heading, ^block
|
|
let link = link_raw.split('|').next().unwrap_or(link_raw);
|
|
let link = link.split('#').next().unwrap_or(link);
|
|
let link = link.split('^').next().unwrap_or(link);
|
|
let link = link.trim().to_lowercase();
|
|
|
|
// 1. Try vault-relative path match (e.g. "arkhost/note name")
|
|
if let Some(&target_idx) = relpath_to_idx.get(&link) {
|
|
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
|
|
} else if let Some(targets) = title_to_idxs.get(&link) {
|
|
// 2. Title match - connect to all notes with this title
|
|
for &target_idx in targets {
|
|
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
|
|
}
|
|
} else if link.contains('/') {
|
|
// 3. Path-based ref: try last segment as title fallback
|
|
if let Some(seg) = link.rsplit('/').next() {
|
|
if let Some(targets) = title_to_idxs.get(seg) {
|
|
for &target_idx in targets {
|
|
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
i += 2;
|
|
}
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
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; }
|
|
// 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();
|
|
// 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.is_empty() {
|
|
return Some(val.to_string());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ── Search ──
|
|
|
|
#[tauri::command]
|
|
pub fn search_notes(
|
|
state: State<'_, AppState>,
|
|
query: String,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
let search_guard = state.search_index.lock().map_err(|e| e.to_string())?;
|
|
let search = search_guard
|
|
.as_ref()
|
|
.ok_or("Search index not initialized")?;
|
|
search.search(&query, limit.unwrap_or(20))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn reindex(state: State<'_, AppState>) -> Result<(), 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 search_guard = state.search_index.lock().map_err(|e| e.to_string())?;
|
|
let search = search_guard
|
|
.as_ref()
|
|
.ok_or("Search index not initialized")?;
|
|
search.rebuild(vault_path)
|
|
}
|
|
|
|
// ── Trash ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_trash(state: State<'_, AppState>) -> Result<TrashContents, 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")?;
|
|
operations::get_trash_contents(vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn restore_note(
|
|
state: State<'_, AppState>,
|
|
trash_path: String,
|
|
dest_notebook: Option<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()
|
|
};
|
|
operations::restore_note(&vault_path, &trash_path, dest_notebook.as_deref())
|
|
}
|
|
|
|
#[tauri::command]
|
|
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()
|
|
};
|
|
operations::restore_notebook(&vault_path, &trash_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
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()
|
|
};
|
|
operations::permanent_delete(&vault_path, &path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn empty_trash(state: State<'_, AppState>) -> Result<(), 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")?;
|
|
operations::empty_trash(vault_path)
|
|
}
|
|
|
|
// ── Vault State ──
|
|
|
|
#[tauri::command]
|
|
pub fn load_vault_state(state: State<'_, AppState>) -> Result<VaultState, 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")?;
|
|
operations::load_vault_state(vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) -> Result<(), 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")?;
|
|
operations::save_vault_state(vault_path, &vault_state)
|
|
}
|
|
|
|
// ── Clipboard ──
|
|
|
|
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
|
|
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
|
|
#[cfg(desktop)]
|
|
#[tauri::command]
|
|
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
|
let mut clipboard =
|
|
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
|
|
let img = clipboard
|
|
.get_image()
|
|
.map_err(|_| "No image on clipboard".to_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);
|
|
encoder.set_color(png::ColorType::Rgba);
|
|
encoder.set_depth(png::BitDepth::Eight);
|
|
let mut writer = encoder
|
|
.write_header()
|
|
.map_err(|e| format!("PNG header failed: {}", e))?;
|
|
writer
|
|
.write_image_data(&img.bytes)
|
|
.map_err(|e| format!("PNG encode failed: {}", e))?;
|
|
}
|
|
Ok(buf)
|
|
}
|
|
|
|
#[cfg(mobile)]
|
|
#[tauri::command]
|
|
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
|
Err("Clipboard image reading not supported on Android".to_string())
|
|
}
|
|
|
|
/// Copy an image file to the system clipboard.
|
|
#[cfg(desktop)]
|
|
#[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 rgba = img.to_rgba8();
|
|
let (w, h) = rgba.dimensions();
|
|
let img_data = arboard::ImageData {
|
|
width: w as usize,
|
|
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)
|
|
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(mobile)]
|
|
#[tauri::command]
|
|
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
|
|
Err("Clipboard image copy not supported on Android".to_string())
|
|
}
|
|
|
|
/// Copy PNG bytes directly to the system clipboard.
|
|
#[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 rgba = img.to_rgba8();
|
|
let (w, h) = rgba.dimensions();
|
|
let img_data = arboard::ImageData {
|
|
width: w as usize,
|
|
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)
|
|
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(mobile)]
|
|
#[tauri::command]
|
|
pub fn copy_png_to_clipboard(_data: Vec<u8>) -> Result<(), String> {
|
|
Err("Clipboard image copy not supported on Android".to_string())
|
|
}
|
|
|
|
// ── Attachments ──
|
|
|
|
#[tauri::command]
|
|
pub fn save_image(
|
|
state: State<'_, AppState>,
|
|
name: String,
|
|
data: Vec<u8>,
|
|
) -> 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")?;
|
|
operations::save_image(vault_path, &name, &data)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn save_attachment(
|
|
state: State<'_, AppState>,
|
|
name: String,
|
|
data: Vec<u8>,
|
|
) -> 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")?;
|
|
operations::save_attachment(vault_path, &name, &data)
|
|
}
|
|
|
|
// ── Notebook Icons ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_notebook_icons(
|
|
state: State<'_, AppState>,
|
|
) -> Result<std::collections::HashMap<String, 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")?;
|
|
operations::load_notebook_icons(vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_notebook_icon(
|
|
state: State<'_, AppState>,
|
|
notebook_relative: String,
|
|
icon_relative: Option<String>,
|
|
) -> Result<(), 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")?;
|
|
operations::set_notebook_icon(vault_path, ¬ebook_relative, icon_relative.as_deref())
|
|
}
|
|
|
|
// ── General Settings ──
|
|
|
|
#[tauri::command]
|
|
pub fn set_general_settings(
|
|
state: State<'_, AppState>,
|
|
compact_notes: bool,
|
|
time_format: String,
|
|
gpu_acceleration: bool,
|
|
autostart: bool,
|
|
pdf_preview: bool,
|
|
pdf_height: u32,
|
|
title_mode: String,
|
|
hide_title_in_body: bool,
|
|
show_line_numbers: bool,
|
|
default_view_mode: bool,
|
|
show_tray_icon: bool,
|
|
close_to_tray: bool,
|
|
enable_wiki_links: bool,
|
|
) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
config.compact_notes = compact_notes;
|
|
config.time_format = time_format;
|
|
config.gpu_acceleration = gpu_acceleration;
|
|
config.autostart = autostart;
|
|
config.pdf_preview = pdf_preview;
|
|
config.pdf_height = pdf_height;
|
|
config.title_mode = title_mode;
|
|
config.hide_title_in_body = hide_title_in_body;
|
|
config.show_line_numbers = show_line_numbers;
|
|
config.default_view_mode = default_view_mode;
|
|
config.show_tray_icon = show_tray_icon;
|
|
config.close_to_tray = close_to_tray;
|
|
config.enable_wiki_links = enable_wiki_links;
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Quick Access ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_quick_access(state: State<'_, AppState>) -> Result<Vec<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")?;
|
|
operations::get_quick_access_notes(vault_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn add_quick_access(state: State<'_, AppState>, note_relative: String) -> Result<(), 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")?;
|
|
operations::add_quick_access(vault_path, ¬e_relative)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn remove_quick_access(
|
|
state: State<'_, AppState>,
|
|
note_relative: String,
|
|
) -> Result<(), 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")?;
|
|
operations::remove_quick_access(vault_path, ¬e_relative)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn reorder_quick_access(state: State<'_, AppState>, paths: Vec<String>) -> Result<(), 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")?;
|
|
operations::save_quick_access(vault_path, &paths)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_vault_stats(state: State<'_, AppState>) -> Result<VaultStats, 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 mut total_notes: u64 = 0;
|
|
let mut total_attachments: u64 = 0;
|
|
let mut notes_size: u64 = 0;
|
|
let mut attachments_size: u64 = 0;
|
|
|
|
for entry in walkdir::WalkDir::new(vault_path)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
{
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
let path_str = path.to_string_lossy();
|
|
// Include .helixnotes/attachments, skip everything else in .helixnotes
|
|
if path_str.contains("/.helixnotes/") && !path_str.contains("/.helixnotes/attachments/") {
|
|
continue;
|
|
}
|
|
// Skip trash
|
|
if path_str.contains("/.trash/") {
|
|
continue;
|
|
}
|
|
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
if ext == "md" {
|
|
total_notes += 1;
|
|
notes_size += size;
|
|
} else {
|
|
total_attachments += 1;
|
|
attachments_size += size;
|
|
}
|
|
}
|
|
|
|
Ok(VaultStats {
|
|
total_notes,
|
|
total_attachments,
|
|
notes_size,
|
|
attachments_size,
|
|
total_size: notes_size + attachments_size,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn import_obsidian(app: AppHandle) -> Result<(), String> {
|
|
let vault_path = {
|
|
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".to_string())?
|
|
};
|
|
// Fire-and-forget: return immediately, do work in background thread
|
|
std::thread::spawn(move || {
|
|
use tauri::Emitter;
|
|
match do_import_obsidian(app.clone(), &vault_path) {
|
|
Ok(result) => {
|
|
let _ = app.emit(
|
|
"import-done",
|
|
serde_json::json!({
|
|
"success": true,
|
|
"files_converted": result.files_converted,
|
|
"links_converted": result.links_converted,
|
|
}),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let _ = app.emit(
|
|
"import-done",
|
|
serde_json::json!({
|
|
"success": false,
|
|
"error": e,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result<ImportResult, String> {
|
|
// Pause file watcher during import
|
|
let state = app.state::<AppState>();
|
|
state
|
|
.importing
|
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
|
|
let result = do_import_obsidian_inner(vault_path);
|
|
|
|
// Resume file watcher
|
|
state
|
|
.importing
|
|
.store(false, std::sync::atomic::Ordering::Relaxed);
|
|
|
|
result
|
|
}
|
|
|
|
fn do_import_obsidian_inner(vault_path: &str) -> Result<ImportResult, String> {
|
|
let vault = std::path::Path::new(vault_path);
|
|
|
|
// Build file index: filename -> relative path from vault root
|
|
let mut file_index: std::collections::HashMap<String, String> =
|
|
std::collections::HashMap::new();
|
|
for entry in walkdir::WalkDir::new(vault)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
{
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
if let Ok(rel) = path.strip_prefix(vault) {
|
|
file_index.insert(name.to_string(), rel.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
let wiki_embed =
|
|
regex::Regex::new(r"!\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]").map_err(|e| e.to_string())?;
|
|
let wiki_link =
|
|
regex::Regex::new(r"\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]").map_err(|e| e.to_string())?;
|
|
let md_img = regex::Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").map_err(|e| e.to_string())?;
|
|
let md_link = regex::Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").map_err(|e| e.to_string())?;
|
|
|
|
let mut files_converted: u64 = 0;
|
|
let mut links_converted: u64 = 0;
|
|
|
|
// Walk all markdown files
|
|
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
let p = e.path();
|
|
p.is_file()
|
|
&& p.extension().and_then(|x| x.to_str()) == Some("md")
|
|
&& !p.to_string_lossy().contains("/.helixnotes/")
|
|
&& !p.to_string_lossy().contains("/.trash/")
|
|
})
|
|
.collect();
|
|
|
|
for entry in &md_files {
|
|
let path = entry.path();
|
|
let content = match std::fs::read_to_string(path) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let note_dir = path.parent().unwrap_or(std::path::Path::new(""));
|
|
|
|
let mut changed = false;
|
|
// Convert embeds first: ![[file.ext]] or ![[file.ext|alt]]
|
|
let after_embeds = wiki_embed
|
|
.replace_all(&content, |caps: ®ex::Captures| {
|
|
changed = true;
|
|
links_converted += 1;
|
|
let file_ref = &caps[1];
|
|
let alt = caps.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
let resolved = resolve_wiki_ref(&file_index, file_ref);
|
|
// Convert vault-root-relative path to note-relative
|
|
let abs_target = vault.join(&resolved);
|
|
let rel = pathdiff(
|
|
abs_target.to_str().unwrap_or(""),
|
|
note_dir.to_str().unwrap_or(""),
|
|
)
|
|
.unwrap_or(resolved);
|
|
let encoded = rel.replace(' ', "%20");
|
|
// Use image syntax only for images/PDFs; regular link for other files
|
|
let ext = std::path::Path::new(file_ref)
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let is_embeddable = matches!(
|
|
ext.as_str(),
|
|
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
|
|
);
|
|
if is_embeddable {
|
|
format!("", alt, encoded)
|
|
} else {
|
|
let display = if alt.is_empty() {
|
|
file_ref.rsplit('/').next().unwrap_or(file_ref)
|
|
} else {
|
|
alt
|
|
};
|
|
format!("[{}]({})", display, encoded)
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
// Convert wiki links: [[note]] or [[note|display]]
|
|
let after_links = wiki_link
|
|
.replace_all(&after_embeds, |caps: ®ex::Captures| {
|
|
changed = true;
|
|
links_converted += 1;
|
|
let note_ref = &caps[1];
|
|
let display = caps.get(2).map(|m| m.as_str()).unwrap_or(note_ref);
|
|
let resolved = resolve_wiki_ref(&file_index, note_ref);
|
|
// Convert vault-root-relative path to note-relative
|
|
let abs_target = vault.join(&resolved);
|
|
let rel = pathdiff(
|
|
abs_target.to_str().unwrap_or(""),
|
|
note_dir.to_str().unwrap_or(""),
|
|
)
|
|
.unwrap_or(resolved);
|
|
let encoded = rel.replace(' ', "%20");
|
|
format!("[{}]({})", display, encoded)
|
|
})
|
|
.to_string();
|
|
|
|
// Fix bare-filename standard markdown references:  where filename has no path separator
|
|
|
|
let after_img_fix = md_img
|
|
.replace_all(&after_links, |caps: ®ex::Captures| {
|
|
let alt = &caps[1];
|
|
let src = &caps[2];
|
|
let decoded = percent_decode(src);
|
|
// Skip URLs and absolute paths
|
|
if decoded.starts_with("http") || decoded.starts_with('/') {
|
|
return format!("", alt, src);
|
|
}
|
|
// Try to resolve bare filename via file index
|
|
if !decoded.contains('/') {
|
|
if let Some(rel) = file_index.get(&decoded) {
|
|
changed = true;
|
|
links_converted += 1;
|
|
let abs_target = vault.join(rel);
|
|
let note_rel = pathdiff(
|
|
abs_target.to_str().unwrap_or(""),
|
|
note_dir.to_str().unwrap_or(""),
|
|
)
|
|
.unwrap_or(rel.clone());
|
|
let encoded = note_rel.replace(' ', "%20");
|
|
return format!("", alt, encoded);
|
|
}
|
|
}
|
|
// Fix vault-root-relative paths: if the path resolves from vault root, make it note-relative
|
|
if decoded.contains('/') {
|
|
let vault_abs = vault.join(&decoded);
|
|
if vault_abs.exists() {
|
|
let note_rel = pathdiff(
|
|
vault_abs.to_str().unwrap_or(""),
|
|
note_dir.to_str().unwrap_or(""),
|
|
)
|
|
.unwrap_or(decoded.clone());
|
|
if note_rel != decoded {
|
|
changed = true;
|
|
links_converted += 1;
|
|
let encoded = note_rel.replace(' ', "%20");
|
|
return format!("", alt, encoded);
|
|
}
|
|
}
|
|
}
|
|
format!("", alt, src)
|
|
})
|
|
.to_string();
|
|
|
|
let after_link_fix = md_link
|
|
.replace_all(&after_img_fix, |caps: ®ex::Captures| {
|
|
let display = &caps[1];
|
|
let href = &caps[2];
|
|
let decoded = percent_decode(href);
|
|
// Skip URLs and absolute paths
|
|
if decoded.starts_with("http") || decoded.starts_with('/') {
|
|
return format!("[{}]({})", display, href);
|
|
}
|
|
// Try to resolve bare filename via file index
|
|
if !decoded.contains('/') {
|
|
if let Some(rel) = file_index.get(&decoded) {
|
|
changed = true;
|
|
links_converted += 1;
|
|
let abs_target = vault.join(rel);
|
|
let note_rel = pathdiff(
|
|
abs_target.to_str().unwrap_or(""),
|
|
note_dir.to_str().unwrap_or(""),
|
|
)
|
|
.unwrap_or(rel.clone());
|
|
let encoded = note_rel.replace(' ', "%20");
|
|
return format!("[{}]({})", display, encoded);
|
|
}
|
|
}
|
|
// Fix vault-root-relative paths
|
|
if decoded.contains('/') {
|
|
let vault_abs = vault.join(&decoded);
|
|
if vault_abs.exists() {
|
|
let note_rel = pathdiff(
|
|
vault_abs.to_str().unwrap_or(""),
|
|
note_dir.to_str().unwrap_or(""),
|
|
)
|
|
.unwrap_or(decoded.clone());
|
|
if note_rel != decoded {
|
|
changed = true;
|
|
links_converted += 1;
|
|
let encoded = note_rel.replace(' ', "%20");
|
|
return format!("[{}]({})", display, encoded);
|
|
}
|
|
}
|
|
}
|
|
format!("[{}]({})", display, href)
|
|
})
|
|
.to_string();
|
|
|
|
if changed {
|
|
let _ = std::fs::write(path, after_link_fix);
|
|
files_converted += 1;
|
|
}
|
|
}
|
|
|
|
// ── Phase 2: Move all non-.md files into .helixnotes/attachments/ preserving structure ──
|
|
let attachments_dir = vault.join(".helixnotes").join("attachments");
|
|
let _ = std::fs::create_dir_all(&attachments_dir);
|
|
|
|
let attachment_files: Vec<_> = walkdir::WalkDir::new(vault)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
let p = e.path();
|
|
if !p.is_file() {
|
|
return false;
|
|
}
|
|
if p.extension().and_then(|x| x.to_str()) == Some("md") {
|
|
return false;
|
|
}
|
|
let path_str = p.to_string_lossy();
|
|
!path_str.contains("/.helixnotes/")
|
|
&& !path_str.contains("/.obsidian/")
|
|
&& !path_str.contains("/.trash/")
|
|
&& !path_str.contains("/.stfolder")
|
|
&& !path_str.contains("/.claude/")
|
|
})
|
|
.collect();
|
|
|
|
// Map: old vault-relative path -> new vault-relative path (preserving structure)
|
|
let mut moved_files: std::collections::HashMap<String, String> =
|
|
std::collections::HashMap::new();
|
|
|
|
for entry in &attachment_files {
|
|
let src_path = entry.path();
|
|
let old_rel = match src_path.strip_prefix(vault) {
|
|
Ok(r) => r.to_string_lossy().to_string(),
|
|
Err(_) => continue,
|
|
};
|
|
|
|
// Preserve directory structure: ArkHost/Files/img.png -> .helixnotes/attachments/ArkHost/Files/img.png
|
|
let new_rel = format!(".helixnotes/attachments/{}", old_rel);
|
|
let dest_path = vault.join(&new_rel);
|
|
|
|
if let Some(parent) = dest_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
|
|
if std::fs::rename(src_path, &dest_path).is_ok()
|
|
|| std::fs::copy(src_path, &dest_path)
|
|
.and_then(|_| std::fs::remove_file(src_path))
|
|
.is_ok()
|
|
{
|
|
moved_files.insert(old_rel, new_rel);
|
|
}
|
|
}
|
|
|
|
// ── Phase 3: Rewrite references in markdown files to point to new locations ──
|
|
// Use regex to only replace inside  and [...](...) patterns
|
|
if !moved_files.is_empty() {
|
|
let md_ref = regex::Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
|
|
|
|
let md_files_pass2: Vec<_> = walkdir::WalkDir::new(vault)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
let p = e.path();
|
|
p.is_file()
|
|
&& p.extension().and_then(|x| x.to_str()) == Some("md")
|
|
&& !p.to_string_lossy().contains("/.helixnotes/")
|
|
&& !p.to_string_lossy().contains("/.trash/")
|
|
})
|
|
.collect();
|
|
|
|
for entry in &md_files_pass2 {
|
|
let path = entry.path();
|
|
let content = match std::fs::read_to_string(path) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let note_dir = path.parent().unwrap_or(std::path::Path::new(""));
|
|
let mut changed_p3 = false;
|
|
|
|
let new_content = md_ref
|
|
.replace_all(&content, |caps: ®ex::Captures| {
|
|
let prefix = &caps[1]; // ![alt] or [text]
|
|
let href = &caps[2];
|
|
let decoded_href = percent_decode(href);
|
|
|
|
// Skip URLs
|
|
if decoded_href.starts_with("http") || decoded_href.starts_with("data:") {
|
|
return format!("{}({})", prefix, href);
|
|
}
|
|
|
|
// Resolve the href to a vault-relative path (files already moved, can't use .exists())
|
|
// Try note-dir-relative first, then vault-root-relative
|
|
let abs_from_note = note_dir.join(&decoded_href);
|
|
let rel_from_note = abs_from_note
|
|
.strip_prefix(vault)
|
|
.ok()
|
|
.map(|r| r.to_string_lossy().to_string());
|
|
let rel_from_vault = decoded_href.clone();
|
|
|
|
// Check note-relative resolution first, then vault-root
|
|
let old_vault_rel = rel_from_note
|
|
.as_ref()
|
|
.filter(|r| moved_files.contains_key(r.as_str()))
|
|
.cloned()
|
|
.or_else(|| {
|
|
if moved_files.contains_key(&rel_from_vault) {
|
|
Some(rel_from_vault)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
if let Some(old_rel) = old_vault_rel {
|
|
if let Some(new_rel) = moved_files.get(&old_rel) {
|
|
changed_p3 = true;
|
|
let new_abs = vault.join(new_rel);
|
|
let new_note_rel = pathdiff(
|
|
new_abs.to_str().unwrap_or(""),
|
|
note_dir.to_str().unwrap_or(""),
|
|
)
|
|
.unwrap_or(new_rel.clone());
|
|
let encoded = new_note_rel.replace(' ', "%20");
|
|
return format!("{}({})", prefix, encoded);
|
|
}
|
|
}
|
|
|
|
format!("{}({})", prefix, href)
|
|
})
|
|
.to_string();
|
|
|
|
if changed_p3 {
|
|
let _ = std::fs::write(path, new_content);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up empty directories left behind after moving files
|
|
cleanup_empty_dirs(vault);
|
|
|
|
Ok(ImportResult {
|
|
files_converted,
|
|
links_converted,
|
|
})
|
|
}
|
|
|
|
/// Remove empty directories recursively (bottom-up), skipping .helixnotes and .obsidian
|
|
fn cleanup_empty_dirs(root: &std::path::Path) {
|
|
let mut dirs: Vec<_> = walkdir::WalkDir::new(root)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.path().is_dir())
|
|
.map(|e| e.path().to_path_buf())
|
|
.collect();
|
|
|
|
// Sort by depth (deepest first) so we clean bottom-up
|
|
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
|
|
|
|
for dir in dirs {
|
|
let dir_str = dir.to_string_lossy();
|
|
if dir_str.contains("/.helixnotes") || dir_str.contains("/.obsidian") || dir == root {
|
|
continue;
|
|
}
|
|
if let Ok(mut entries) = std::fs::read_dir(&dir) {
|
|
if entries.next().is_none() {
|
|
let _ = std::fs::remove_dir(&dir);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compute a relative path from `base` to `target`
|
|
fn pathdiff(target: &str, base: &str) -> Result<String, ()> {
|
|
let target = std::path::Path::new(target);
|
|
let base = std::path::Path::new(base);
|
|
|
|
let target_components: Vec<_> = target.components().collect();
|
|
let base_components: Vec<_> = base.components().collect();
|
|
|
|
// Find common prefix length
|
|
let common = target_components
|
|
.iter()
|
|
.zip(base_components.iter())
|
|
.take_while(|(a, b)| a == b)
|
|
.count();
|
|
|
|
let mut result = std::path::PathBuf::new();
|
|
// Go up from base to common ancestor
|
|
for _ in common..base_components.len() {
|
|
result.push("..");
|
|
}
|
|
// Go down from common ancestor to target
|
|
for component in &target_components[common..] {
|
|
result.push(component);
|
|
}
|
|
|
|
Ok(result.to_string_lossy().to_string())
|
|
}
|
|
|
|
fn percent_decode(s: &str) -> String {
|
|
let mut result = String::with_capacity(s.len());
|
|
let mut chars = s.bytes();
|
|
while let Some(b) = chars.next() {
|
|
if b == b'%' {
|
|
let hi = chars.next();
|
|
let lo = chars.next();
|
|
if let (Some(h), Some(l)) = (hi, lo) {
|
|
if let Ok(byte) = u8::from_str_radix(&format!("{}{}", h as char, l as char), 16) {
|
|
result.push(byte as char);
|
|
continue;
|
|
}
|
|
}
|
|
result.push('%');
|
|
} else {
|
|
result.push(b as char);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn resolve_wiki_ref(
|
|
file_index: &std::collections::HashMap<String, String>,
|
|
reference: &str,
|
|
) -> String {
|
|
let reference = reference.trim();
|
|
// Strip Obsidian heading anchors (#) and block references (^) before lookup
|
|
let reference = reference.split('#').next().unwrap_or(reference).trim();
|
|
let reference = reference.split('^').next().unwrap_or(reference).trim();
|
|
// Direct match by filename
|
|
if let Some(rel) = file_index.get(reference) {
|
|
return rel.clone();
|
|
}
|
|
// Try with .md extension
|
|
let with_md = format!("{}.md", reference);
|
|
if let Some(rel) = file_index.get(&with_md) {
|
|
return rel.clone();
|
|
}
|
|
// Try matching just the filename part (e.g. "folder/note" -> "note.md")
|
|
if let Some(name) = reference.rsplit('/').next() {
|
|
if let Some(rel) = file_index.get(name) {
|
|
return rel.clone();
|
|
}
|
|
let name_md = format!("{}.md", name);
|
|
if let Some(rel) = file_index.get(&name_md) {
|
|
return rel.clone();
|
|
}
|
|
}
|
|
// Fallback: return as-is
|
|
reference.to_string()
|
|
}
|
|
|
|
// ── Open file/URL with system default handler ──
|
|
|
|
fn xdg_open(arg: &str) -> Result<(), String> {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let mut cmd = std::process::Command::new("xdg-open");
|
|
cmd.arg(arg);
|
|
// Clear AppImage environment so child processes find host binaries
|
|
// (e.g. gio-launch-desktop on GNOME)
|
|
if std::env::var("APPIMAGE").is_ok() {
|
|
cmd.env_remove("LD_LIBRARY_PATH")
|
|
.env_remove("LD_PRELOAD")
|
|
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
|
|
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
|
|
if let Ok(original_path) = std::env::var("PATH_ORIG") {
|
|
cmd.env("PATH", original_path);
|
|
}
|
|
}
|
|
cmd.spawn()
|
|
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
std::process::Command::new("open")
|
|
.arg(arg)
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
std::process::Command::new("cmd")
|
|
.args(["/C", "start", "", arg])
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn open_file(path: String) -> Result<(), String> {
|
|
xdg_open(&path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn open_url(url: String) -> Result<(), String> {
|
|
xdg_open(&url)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
|
|
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String> {
|
|
std::fs::write(&destination, &data).map_err(|e| format!("Failed to write file: {}", e))?;
|
|
Ok(())
|
|
}
|
|
|
|
|
|
// ── Backup ──
|
|
|
|
#[tauri::command]
|
|
pub fn create_backup(app: AppHandle) -> Result<(), String> {
|
|
let (vault_path, backup_dir, include_attachments, max_count) = {
|
|
let state = app.state::<AppState>();
|
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
|
let vault_path = config.active_vault.clone().ok_or("No active vault")?;
|
|
let backup_dir = crate::backup::get_backup_dir(&config.backup_location)?;
|
|
(
|
|
vault_path,
|
|
backup_dir,
|
|
config.backup_include_attachments,
|
|
config.backup_max_count,
|
|
)
|
|
};
|
|
|
|
std::thread::spawn(move || {
|
|
use tauri::Emitter;
|
|
match crate::backup::create_backup(&vault_path, &backup_dir, include_attachments) {
|
|
Ok(entry) => {
|
|
// Update last backup time
|
|
let state = app.state::<AppState>();
|
|
if let Ok(mut config) = state.config.lock() {
|
|
config.last_backup_time = Some(entry.created.clone());
|
|
let _ = save_app_config(&config);
|
|
}
|
|
// Cleanup old backups
|
|
let _ = crate::backup::cleanup_old_backups(&backup_dir, max_count);
|
|
let _ = app.emit(
|
|
"backup-done",
|
|
serde_json::json!({
|
|
"success": true,
|
|
"entry": entry,
|
|
}),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let _ = app.emit(
|
|
"backup-done",
|
|
serde_json::json!({
|
|
"success": false,
|
|
"error": e,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_backups(state: State<'_, AppState>) -> Result<Vec<BackupEntry>, String> {
|
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
|
let backup_dir = crate::backup::get_backup_dir(&config.backup_location)?;
|
|
crate::backup::list_backups(&backup_dir)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn restore_backup(app: AppHandle, backup_path: String) -> Result<(), String> {
|
|
let vault_path = {
|
|
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")?
|
|
};
|
|
|
|
std::thread::spawn(move || {
|
|
use tauri::Emitter;
|
|
match crate::backup::restore_backup(&vault_path, &backup_path) {
|
|
Ok(()) => {
|
|
let _ = app.emit(
|
|
"restore-done",
|
|
serde_json::json!({
|
|
"success": true,
|
|
}),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let _ = app.emit(
|
|
"restore-done",
|
|
serde_json::json!({
|
|
"success": false,
|
|
"error": e,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_backup(backup_path: String) -> Result<(), String> {
|
|
crate::backup::delete_backup(&backup_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_backup_settings(
|
|
state: State<'_, AppState>,
|
|
enabled: bool,
|
|
frequency: String,
|
|
max_count: u32,
|
|
location: Option<String>,
|
|
include_attachments: bool,
|
|
) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
config.backup_enabled = enabled;
|
|
config.backup_frequency = frequency;
|
|
config.backup_max_count = max_count;
|
|
config.backup_location = location;
|
|
config.backup_include_attachments = include_attachments;
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Version History ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_note_versions(
|
|
state: State<'_, AppState>,
|
|
note_id: String,
|
|
) -> Result<Vec<crate::types::VersionEntry>, 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")?;
|
|
crate::history::list_versions(vault_path, ¬e_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_version(
|
|
state: State<'_, AppState>,
|
|
path: String,
|
|
note_id: String,
|
|
) -> Result<(), 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 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(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_note_version_content(
|
|
state: State<'_, AppState>,
|
|
note_id: String,
|
|
timestamp: 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")?;
|
|
crate::history::get_version(vault_path, ¬e_id, ×tamp)
|
|
}
|
|
|
|
// ── AI ──
|
|
|
|
#[tauri::command]
|
|
pub fn set_ai_settings(
|
|
state: State<'_, AppState>,
|
|
provider: Option<String>,
|
|
api_key: Option<String>,
|
|
model: String,
|
|
writing_style: Option<String>,
|
|
base_url: Option<String>,
|
|
ollama_api_key: Option<String>,
|
|
openai_compatible_base_url: Option<String>,
|
|
openai_compatible_api_key: Option<String>,
|
|
) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
let key = api_key.filter(|k| !k.is_empty());
|
|
match provider.as_deref() {
|
|
Some("openai") => config.openai_api_key = key,
|
|
Some("ollama") => {
|
|
config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty());
|
|
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_api_key = openai_compatible_api_key.filter(|k| !k.is_empty());
|
|
}
|
|
_ => config.ai_api_key = key,
|
|
}
|
|
config.ai_provider = provider;
|
|
config.ai_model = model;
|
|
config.ai_writing_style = writing_style.filter(|s| !s.trim().is_empty());
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
|
let (provider, api_key, model, base_url) = {
|
|
let state = app.state::<AppState>();
|
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
|
let provider = config
|
|
.ai_provider
|
|
.clone()
|
|
.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" => config.openai_api_key.clone(),
|
|
_ => config.ai_api_key.clone(),
|
|
}
|
|
.ok_or("No API key configured")?;
|
|
let model = config.ai_model.clone();
|
|
let base_url = match provider.as_str() {
|
|
"openai_compatible" => config.openai_compatible_base_url.clone(),
|
|
_ => config.ollama_base_url.clone(),
|
|
};
|
|
(provider, key, model, base_url)
|
|
};
|
|
|
|
std::thread::spawn(move || {
|
|
use tauri::Emitter;
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
let result = rt.block_on(crate::ai::test_connection(
|
|
&provider,
|
|
&api_key,
|
|
&model,
|
|
base_url.as_deref(),
|
|
));
|
|
match result {
|
|
Ok(msg) => {
|
|
let _ = app.emit(
|
|
"ai-test-result",
|
|
serde_json::json!({ "success": true, "message": msg }),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let _ = app.emit(
|
|
"ai-test-result",
|
|
serde_json::json!({ "success": false, "error": e }),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
// ── Sync (WebDAV) ──
|
|
|
|
fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> {
|
|
if config.sync_provider.as_deref() != Some("webdav") {
|
|
return Err("Sync is not configured".to_string());
|
|
}
|
|
let url = config
|
|
.webdav_url
|
|
.clone()
|
|
.filter(|u| !u.trim().is_empty())
|
|
.ok_or("WebDAV URL is not set")?;
|
|
Ok(crate::sync::WebdavConfig {
|
|
url,
|
|
username: config.webdav_username.clone().unwrap_or_default(),
|
|
password: config.webdav_password.clone().unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_sync_settings(
|
|
state: State<'_, AppState>,
|
|
provider: Option<String>,
|
|
url: Option<String>,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
sync_on_open: bool,
|
|
sync_on_change: bool,
|
|
sync_interval_minutes: u32,
|
|
) -> Result<(), String> {
|
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
|
config.sync_provider = provider.filter(|p| !p.is_empty());
|
|
config.webdav_url = url.filter(|u| !u.trim().is_empty());
|
|
config.webdav_username = username.filter(|u| !u.is_empty());
|
|
config.webdav_password = password.filter(|p| !p.is_empty());
|
|
config.sync_on_open = sync_on_open;
|
|
config.sync_on_change = sync_on_change;
|
|
config.sync_interval_minutes = sync_interval_minutes;
|
|
save_app_config(&config)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn test_sync_connection(app: AppHandle) -> Result<(), String> {
|
|
let cfg = {
|
|
let state = app.state::<AppState>();
|
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
|
sync_config_from(&config)?
|
|
};
|
|
std::thread::spawn(move || {
|
|
use tauri::Emitter;
|
|
match crate::sync::test_connection(cfg) {
|
|
Ok(msg) => {
|
|
let _ = app.emit(
|
|
"sync-test-result",
|
|
serde_json::json!({ "success": true, "message": msg }),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let _ = app.emit(
|
|
"sync-test-result",
|
|
serde_json::json!({ "success": false, "error": e }),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn sync_now(app: AppHandle) -> Result<(), String> {
|
|
use std::sync::atomic::Ordering;
|
|
// Guard against overlapping syncs (manual button + interval + on-change can collide).
|
|
if app.state::<AppState>().syncing.swap(true, Ordering::SeqCst) {
|
|
return Ok(()); // a sync is already running
|
|
}
|
|
let (vault, cfg) = {
|
|
let state = app.state::<AppState>();
|
|
let config = match state.config.lock() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
state.syncing.store(false, Ordering::SeqCst);
|
|
return Err(e.to_string());
|
|
}
|
|
};
|
|
let gathered = config
|
|
.active_vault
|
|
.clone()
|
|
.ok_or_else(|| "No active vault".to_string())
|
|
.and_then(|v| sync_config_from(&config).map(|c| (v, c)));
|
|
match gathered {
|
|
Ok(vc) => vc,
|
|
Err(e) => {
|
|
drop(config);
|
|
state.syncing.store(false, Ordering::SeqCst);
|
|
return Err(e);
|
|
}
|
|
}
|
|
};
|
|
std::thread::spawn(move || {
|
|
use tauri::Emitter;
|
|
let result = crate::sync::run_sync(app.clone(), vault, cfg);
|
|
app.state::<AppState>().syncing.store(false, Ordering::SeqCst);
|
|
match result {
|
|
Ok(summary) => {
|
|
let ts = chrono::Utc::now().to_rfc3339();
|
|
if let Ok(mut config) = app.state::<AppState>().config.lock() {
|
|
config.last_sync_time = Some(ts.clone());
|
|
let _ = save_app_config(&config);
|
|
}
|
|
let _ = app.emit(
|
|
"sync-done",
|
|
serde_json::json!({ "success": true, "summary": summary, "last_sync_time": ts }),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let _ = app.emit("sync-error", serde_json::json!({ "success": false, "error": e }));
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn ai_ask(
|
|
app: AppHandle,
|
|
action: String,
|
|
text: String,
|
|
custom_prompt: Option<String>,
|
|
request_id: String,
|
|
) -> Result<(), String> {
|
|
let (provider, api_key, model, writing_style, base_url) = {
|
|
let state = app.state::<AppState>();
|
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
|
let provider = config
|
|
.ai_provider
|
|
.clone()
|
|
.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" => config.openai_api_key.clone(),
|
|
_ => config.ai_api_key.clone(),
|
|
}
|
|
.ok_or("No API key configured. Go to Settings > AI to set up your API key.")?;
|
|
let model = config.ai_model.clone();
|
|
let style = config.ai_writing_style.clone();
|
|
let base_url = match provider.as_str() {
|
|
"openai_compatible" => config.openai_compatible_base_url.clone(),
|
|
_ => config.ollama_base_url.clone(),
|
|
};
|
|
(provider, key, model, style, base_url)
|
|
};
|
|
|
|
let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \
|
|
You help users improve, rewrite, summarize, and transform their text. \
|
|
Return ONLY the resulting text - no explanations, no markdown code fences, no preamble. \
|
|
Preserve the original language of the text unless specifically asked to translate. \
|
|
Preserve any markdown formatting (bold, italic, links, images, tables, etc.) unless the user asks to change it. \
|
|
If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions - they represent images and embedded files.".to_string();
|
|
|
|
if let Some(ref style) = writing_style {
|
|
system_prompt.push_str(&format!(
|
|
"\n\nThe user's preferred writing style: {}",
|
|
style
|
|
));
|
|
}
|
|
|
|
let user_message = match action.as_str() {
|
|
"improve" => format!("Improve the writing quality of this text while keeping the same meaning and tone:\n\n{}", text),
|
|
"fix_grammar" => format!("Fix all grammar, spelling, and punctuation errors in this text. Keep the original style:\n\n{}", text),
|
|
"shorter" => format!("Make this text more concise while keeping the key points:\n\n{}", text),
|
|
"longer" => format!("Expand this text with more detail while keeping the same style and tone:\n\n{}", text),
|
|
"professional" => format!("Rewrite this text in a professional, formal tone:\n\n{}", text),
|
|
"friendly" => format!("Rewrite this text in a casual, friendly tone:\n\n{}", text),
|
|
"summarize" => format!("Write a brief summary of this text:\n\n{}", text),
|
|
"explain" => format!("Explain this text in simpler terms:\n\n{}", text),
|
|
"translate_en" => format!("Translate this text to English:\n\n{}", text),
|
|
"translate_nl" => format!("Translate this text to Dutch:\n\n{}", text),
|
|
"translate_de" => format!("Translate this text to German:\n\n{}", text),
|
|
"translate_fr" => format!("Translate this text to French:\n\n{}", text),
|
|
"translate_es" => format!("Translate this text to Spanish:\n\n{}", text),
|
|
"custom" => {
|
|
let prompt = custom_prompt.unwrap_or_else(|| "Improve this text".to_string());
|
|
format!("{}\n\n{}", prompt, text)
|
|
}
|
|
_ => format!("Improve this text:\n\n{}", text),
|
|
};
|
|
|
|
crate::ai::ai_request(
|
|
app,
|
|
provider,
|
|
api_key,
|
|
model,
|
|
system_prompt,
|
|
user_message,
|
|
request_id,
|
|
base_url,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
// On mobile (Android + iOS) the OS config dir is injected at startup via the Tauri
|
|
// path resolver, since dirs::config_dir() is not reliable in the app sandbox.
|
|
static MOBILE_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
|
|
|
pub fn set_mobile_config_dir(path: std::path::PathBuf) {
|
|
let _ = MOBILE_CONFIG_DIR.set(path);
|
|
}
|
|
|
|
fn app_config_path() -> Result<std::path::PathBuf, String> {
|
|
// Prefer the injected mobile dir when present (set only on mobile); fall back to
|
|
// the platform config dir on desktop.
|
|
let app_dir = if let Some(mobile_dir) = MOBILE_CONFIG_DIR.get() {
|
|
mobile_dir.join("helixnotes")
|
|
} else if let Some(config_dir) = dirs::config_dir() {
|
|
config_dir.join("helixnotes")
|
|
} else {
|
|
return Err("Config directory not available yet".to_string());
|
|
};
|
|
std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
|
|
Ok(app_dir.join("config.json"))
|
|
}
|
|
|
|
pub fn load_app_config() -> AppConfig {
|
|
app_config_path()
|
|
.ok()
|
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
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())?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Install Type Detection ──
|
|
|
|
#[tauri::command]
|
|
pub fn get_install_type() -> String {
|
|
if cfg!(target_os = "macos") {
|
|
"macos".to_string()
|
|
} else if cfg!(target_os = "windows") {
|
|
"windows".to_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)
|
|
})
|
|
{
|
|
"aur".to_string()
|
|
} else if std::env::var("APPIMAGE").is_ok() {
|
|
"appimage".to_string()
|
|
} else {
|
|
"native".to_string()
|
|
}
|
|
}
|