diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ac123ed..02c0ddb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1276,6 +1276,9 @@ pub fn import_obsidian(app: AppHandle) -> Result<(), String> { "success": true, "files_converted": result.files_converted, "links_converted": result.links_converted, + "frontmatter_normalized": result.frontmatter_normalized, + "syntax_converted": result.syntax_converted, + "attachments_moved": result.attachments_moved, }), ); } @@ -1294,15 +1297,13 @@ pub fn import_obsidian(app: AppHandle) -> Result<(), String> { } fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result { - // Pause file watcher during import let state = app.state::(); state .importing .store(true, std::sync::atomic::Ordering::Relaxed); - let result = do_import_obsidian_inner(vault_path); + let result = crate::vault::import::import(vault_path); - // Resume file watcher state .importing .store(false, std::sync::atomic::Ordering::Relaxed); @@ -1310,413 +1311,6 @@ fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result Result { - let vault = std::path::Path::new(vault_path); - - // Build file index: filename -> relative path from vault root - let mut file_index: std::collections::HashMap = - 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: ![alt](filename.ext) 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 = - 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 { - 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()) -} - // ── Orphaned attachment cleanup ── #[derive(serde::Serialize)] @@ -1853,37 +1447,6 @@ fn percent_decode(s: &str) -> String { result } -fn resolve_wiki_ref( - file_index: &std::collections::HashMap, - 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> { diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 9425592..96bb08c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -393,6 +393,9 @@ pub struct FileEvent { pub struct ImportResult { pub files_converted: u64, pub links_converted: u64, + pub frontmatter_normalized: u64, + pub syntax_converted: u64, + pub attachments_moved: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 0e64dfc..6d7491d 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -15,7 +15,7 @@ struct RawFrontmatter { } /// Try to parse a date string in multiple common formats. -fn parse_date_flexible(s: &str) -> Option> { +pub fn parse_date_flexible(s: &str) -> Option> { let s = s.trim(); // RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00) if let Ok(dt) = s.parse::>() { @@ -191,7 +191,7 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str format!("---\n{}---\n{}", yaml_str, body_with_heading) } -fn filename_to_title(filename: &str) -> String { +pub fn filename_to_title(filename: &str) -> String { let stem = filename.trim_end_matches(".md"); // Only replace dashes/underscores acting as word separators (between non-space chars). // Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle"). diff --git a/src-tauri/src/vault/import.rs b/src-tauri/src/vault/import.rs new file mode 100644 index 0000000..c7291f4 --- /dev/null +++ b/src-tauri/src/vault/import.rs @@ -0,0 +1,1008 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use chrono::Utc; +use gray_matter::engine::YAML; +use gray_matter::Matter; +use regex::Regex; +use uuid::Uuid; + +use crate::types::{ImportResult, NoteMeta}; +use crate::vault::frontmatter; + +pub fn import(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + + let file_index = build_file_index(vault); + + 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/") + }) + .map(|e| e.path().to_path_buf()) + .collect(); + + let wiki_embed_re = + Regex::new(r"!\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]").map_err(|e| e.to_string())?; + let wiki_link_re = + Regex::new(r"\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]").map_err(|e| e.to_string())?; + let highlight_re = Regex::new(r"==([^\n=]+?)==").map_err(|e| e.to_string())?; + let comment_re = Regex::new(r"(?s)%%([\s\S]*?)%%").map_err(|e| e.to_string())?; + let md_img_re = Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").map_err(|e| e.to_string())?; + let md_link_re = Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").map_err(|e| e.to_string())?; + + let mut result = ImportResult { + files_converted: 0, + links_converted: 0, + frontmatter_normalized: 0, + syntax_converted: 0, + attachments_moved: 0, + }; + + for path in &md_files { + let raw = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + let note_dir = path.parent().unwrap_or(Path::new("")); + + let mut content = raw.clone(); + let mut changed = false; + + let renamed = rename_deprecated_properties(&content); + if renamed != content { + content = renamed; + changed = true; + } + + let (meta, body) = normalize_frontmatter(&content, path); + let normalized = frontmatter::merge_frontmatter(&content, &meta, &body); + if normalized != content { + content = normalized; + changed = true; + result.frontmatter_normalized += 1; + } + + let after_syntax = convert_syntax(&content, &highlight_re, &comment_re); + if after_syntax != content { + content = after_syntax; + changed = true; + result.syntax_converted += 1; + } + + let links_before = result.links_converted; + + let after_embeds = wiki_embed_re + .replace_all(&content, |caps: ®ex::Captures| { + let file_ref = &caps[1]; + let alt_param = caps.get(2).map(|m| m.as_str()).unwrap_or(""); + let (file_part, anchor) = split_anchor(file_ref); + if file_part.is_empty() { + if let Some(a) = anchor { + let display = if alt_param.is_empty() { a } else { alt_param }; + result.links_converted += 1; + return format!("[{}](#{})", display, a); + } + } + let resolved = resolve_wiki_ref(&file_index, file_part); + 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"); + let link_target = match anchor { + Some(a) => format!("{}#{}", encoded, a), + None => encoded, + }; + let ext = Path::new(file_part) + .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 { + 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) { + file_part.rsplit('/').next().unwrap_or(file_part) + } else { + alt_param + }; + result.links_converted += 1; + format!("[{}]({})", display, link_target) + } + }) + .to_string(); + content = after_embeds; + + let after_links = wiki_link_re + .replace_all(&content, |caps: ®ex::Captures| { + let note_ref = &caps[1]; + let display_param = caps.get(2).map(|m| m.as_str()).unwrap_or(""); + 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 }; + result.links_converted += 1; + return format!("[{}](#{})", display, a); + } + } + let resolved = resolve_wiki_ref(&file_index, file_part); + 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"); + let link_target = match anchor { + Some(a) => format!("{}#{}", encoded, a), + None => encoded, + }; + let display = if display_param.is_empty() { + note_ref + } else { + display_param + }; + result.links_converted += 1; + format!("[{}]({})", display, link_target) + }) + .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); + + if result.links_converted > links_before { + changed = true; + } + + if changed { + let _ = std::fs::write(path, &content); + result.files_converted += 1; + } + } + + let moved = move_attachments(vault)?; + result.attachments_moved = moved.len() as u64; + + if !moved.is_empty() { + rewrite_attachment_refs(vault, &moved)?; + } + + cleanup_empty_dirs(vault); + + Ok(result) +} + +fn build_file_index(vault: &Path) -> HashMap { + let mut index = 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) { + index.insert(name.to_string(), rel.to_string_lossy().to_string()); + } + } + } + index +} + +fn rename_deprecated_properties(raw: &str) -> String { + let matter = Matter::::new(); + let result = matter.parse(raw); + + if result.matter.trim().is_empty() { + return raw.to_string(); + } + + let mut mapping: serde_yaml::Mapping = match serde_yaml::from_str(&result.matter) { + Ok(m) => m, + Err(_) => return raw.to_string(), + }; + + let mut renamed = false; + for (old, new) in &[("alias", "aliases"), ("cssclass", "cssclasses")] { + let old_key = serde_yaml::Value::String((*old).into()); + let new_key = serde_yaml::Value::String((*new).into()); + if !mapping.contains_key(&new_key) { + if let Some(val) = mapping.remove(&old_key) { + mapping.insert(new_key, val); + renamed = true; + } + } else { + if mapping.remove(&old_key).is_some() { + renamed = true; + } + } + } + + if !renamed { + return raw.to_string(); + } + + let yaml_str = match serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping)) { + Ok(s) => s, + Err(_) => return raw.to_string(), + }; + + format!("---\n{}---\n{}", yaml_str, result.content) +} + +fn normalize_frontmatter(raw: &str, path: &Path) -> (NoteMeta, String) { + let matter = Matter::::new(); + let result = matter.parse(raw); + let yaml_str = result.matter.trim(); + + let mapping: serde_yaml::Mapping = if yaml_str.is_empty() { + serde_yaml::Mapping::new() + } else { + match serde_yaml::from_str::(yaml_str) { + Ok(m) => m, + Err(_) => serde_yaml::Mapping::new(), + } + }; + + let tags = normalize_tags(&mapping); + + let title = mapping + .get(&serde_yaml::Value::String("title".into())) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + if let Some(h) = extract_heading_title(&result.content) { + h + } else { + let fname = path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("Untitled"); + frontmatter::filename_to_title(fname) + } + }); + + let id = mapping + .get(&serde_yaml::Value::String("id".into())) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + let created = ["created", "date", "ctime"] + .iter() + .find_map(|key| { + mapping + .get(&serde_yaml::Value::String((*key).into())) + .and_then(|v| v.as_str()) + .and_then(|s| frontmatter::parse_date_flexible(s)) + }) + .or_else(|| file_created(path)) + .unwrap_or_else(Utc::now); + + let modified = ["modified", "mtime", "lastmod"] + .iter() + .find_map(|key| { + mapping + .get(&serde_yaml::Value::String((*key).into())) + .and_then(|v| v.as_str()) + .and_then(|s| frontmatter::parse_date_flexible(s)) + }) + .or_else(|| file_modified(path)) + .unwrap_or_else(Utc::now); + + let pinned = mapping + .get(&serde_yaml::Value::String("pinned".into())) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let meta = NoteMeta { + id, + title, + tags, + pinned, + created, + modified, + }; + (meta, result.content) +} + +fn normalize_tags(mapping: &serde_yaml::Mapping) -> Vec { + let mut tags = Vec::new(); + let mut seen = HashSet::new(); + + for key in &["tags", "tag"] { + 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()) { + tags.push(cleaned); + } + } + } + } + tags +} + +fn yaml_value_to_strings(val: &serde_yaml::Value) -> Vec { + match val { + serde_yaml::Value::String(s) => { + if s.contains(',') { + s.split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() + } else { + vec![s.clone()] + } + } + serde_yaml::Value::Sequence(seq) => seq + .iter() + .filter_map(|v| match v { + serde_yaml::Value::String(s) => Some(s.clone()), + serde_yaml::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .collect(), + serde_yaml::Value::Number(n) => vec![n.to_string()], + serde_yaml::Value::Bool(b) => vec![b.to_string()], + _ => Vec::new(), + } +} + +fn extract_heading_title(body: &str) -> Option { + for line in body.lines() { + let trimmed = line.trim_start(); + if trimmed.is_empty() { + continue; + } + if let Some(rest) = trimmed.strip_prefix("# ") { + return Some(rest.trim().to_string()); + } + return None; + } + None +} + +fn file_created(path: &Path) -> Option> { + std::fs::metadata(path) + .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()) + }) +} + +fn file_modified(path: &Path) -> Option> { + std::fs::metadata(path) + .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()) + }) +} + +fn convert_syntax(content: &str, highlight_re: &Regex, comment_re: &Regex) -> String { + let segments = split_code_segments(content); + let mut result = String::with_capacity(content.len()); + + for (is_code, text) in segments { + if is_code { + result.push_str(&text); + } else { + let with_marks = highlight_re.replace_all(&text, "$1"); + let with_comments = comment_re.replace_all(&with_marks, ""); + result.push_str(&with_comments); + } + } + result +} + +fn split_code_segments(content: &str) -> Vec<(bool, String)> { + let mut segments = Vec::new(); + let mut current = String::new(); + let mut in_code = false; + let mut fence_char = '`'; + + for line in content.split_inclusive('\n') { + let trimmed = line.trim_start(); + + let is_fence = if in_code { + trimmed.starts_with(&fence_char.to_string().repeat(3)) + } else if trimmed.starts_with("```") { + fence_char = '`'; + true + } else if trimmed.starts_with("~~~") { + fence_char = '~'; + true + } else { + false + }; + + if is_fence { + current.push_str(line); + segments.push((in_code, std::mem::take(&mut current))); + in_code = !in_code; + } else { + current.push_str(line); + } + } + if !current.is_empty() { + segments.push((in_code, current)); + } + segments +} + +fn fix_md_image_refs( + content: &str, + re: &Regex, + vault: &Path, + note_dir: &Path, + file_index: &HashMap, + links_converted: &mut u64, +) -> String { + re.replace_all(content, |caps: ®ex::Captures| { + let alt = &caps[1]; + let src = &caps[2]; + let decoded = percent_decode(src); + if decoded.starts_with("http") || decoded.starts_with('/') || decoded.starts_with("data:") { + return format!("![{}]({})", alt, src); + } + if !decoded.contains('/') { + if let Some(rel) = file_index.get(&decoded) { + *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()); + return format!("![{}]({})", alt, note_rel.replace(' ', "%20")); + } + } + 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 { + *links_converted += 1; + return format!("![{}]({})", alt, note_rel.replace(' ', "%20")); + } + } + } + format!("![{}]({})", alt, src) + }) + .to_string() +} + +fn fix_md_link_refs( + content: &str, + re: &Regex, + vault: &Path, + note_dir: &Path, + file_index: &HashMap, + links_converted: &mut u64, +) -> String { + re.replace_all(content, |caps: ®ex::Captures| { + 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:") + || decoded.starts_with('#') + { + return format!("[{}]({})", display, href); + } + if !decoded.contains('/') { + if let Some(rel) = file_index.get(&decoded) { + *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()); + return format!("[{}]({})", display, note_rel.replace(' ', "%20")); + } + } + 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 { + *links_converted += 1; + return format!("[{}]({})", display, note_rel.replace(' ', "%20")); + } + } + } + format!("[{}]({})", display, href) + }) + .to_string() +} + +fn move_attachments( + vault: &Path, +) -> Result, String> { + 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/") + }) + .map(|e| e.path().to_path_buf()) + .collect(); + + let mut moved: HashMap = HashMap::new(); + + for src_path in &attachment_files { + let old_rel = match src_path.strip_prefix(vault) { + Ok(r) => r.to_string_lossy().to_string(), + Err(_) => continue, + }; + 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.insert(old_rel, new_rel); + } + } + Ok(moved) +} + +fn rewrite_attachment_refs( + vault: &Path, + moved: &HashMap, +) -> Result<(), String> { + let md_ref = Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?; + + 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/") + }) + .map(|e| e.path().to_path_buf()) + .collect(); + + for path in &md_files { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + let note_dir = path.parent().unwrap_or(Path::new("")); + + let new_content = md_ref + .replace_all(&content, |caps: ®ex::Captures| { + let prefix = &caps[1]; + let href = &caps[2]; + let decoded_href = percent_decode(href); + + if decoded_href.starts_with("http") || decoded_href.starts_with("data:") { + return format!("{}({})", prefix, href); + } + + 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(); + + let old_vault_rel = rel_from_note + .as_ref() + .filter(|r| moved.contains_key(r.as_str())) + .cloned() + .or_else(|| { + if moved.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.get(&old_rel) { + 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()); + return format!("{}({})", prefix, new_note_rel.replace(' ', "%20")); + } + } + format!("{}({})", prefix, href) + }) + .to_string(); + + if new_content != content { + let _ = std::fs::write(path, new_content); + } + } + Ok(()) +} + +fn cleanup_empty_dirs(root: &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(); + + 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); + } + } + } +} + +fn split_anchor(reference: &str) -> (&str, Option<&str>) { + if let Some(idx) = reference.find('#') { + let file_part = &reference[..idx]; + let anchor = &reference[idx + 1..]; + if anchor.is_empty() { + (reference, None) + } else { + (file_part, Some(anchor)) + } + } else { + (reference, None) + } +} + +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') +} + +fn resolve_wiki_ref(file_index: &HashMap, reference: &str) -> String { + let reference = reference.trim(); + let reference = reference.split('#').next().unwrap_or(reference).trim(); + let reference = reference.split('^').next().unwrap_or(reference).trim(); + + if let Some(rel) = file_index.get(reference) { + return rel.clone(); + } + let with_md = format!("{}.md", reference); + if let Some(rel) = file_index.get(&with_md) { + return rel.clone(); + } + 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(); + } + } + reference.to_string() +} + +fn pathdiff(target: &str, base: &str) -> Result { + let target = Path::new(target); + let base = Path::new(base); + + let target_components: Vec<_> = target.components().collect(); + let base_components: Vec<_> = base.components().collect(); + + let common = target_components + .iter() + .zip(base_components.iter()) + .take_while(|(a, b)| a == b) + .count(); + + let mut result = std::path::PathBuf::new(); + for _ in common..base_components.len() { + result.push(".."); + } + 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 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_tags_comma_string() { + let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags: \"a, b, c\"").unwrap(); + let tags = normalize_tags(&mapping); + assert_eq!(tags, vec!["a", "b", "c"]); + } + + #[test] + fn test_normalize_tags_single_string() { + let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags: only-one").unwrap(); + let tags = normalize_tags(&mapping); + assert_eq!(tags, vec!["only-one"]); + } + + #[test] + fn test_normalize_tags_array() { + let mapping: serde_yaml::Mapping = + serde_yaml::from_str("tags:\n - alpha\n - beta").unwrap(); + let tags = normalize_tags(&mapping); + assert_eq!(tags, vec!["alpha", "beta"]); + } + + #[test] + fn test_normalize_tags_singular_tag_key() { + let mapping: serde_yaml::Mapping = serde_yaml::from_str("tag: solo").unwrap(); + let tags = normalize_tags(&mapping); + assert_eq!(tags, vec!["solo"]); + } + + #[test] + fn test_normalize_tags_merge_singular_and_plural() { + let mapping: serde_yaml::Mapping = + serde_yaml::from_str("tags:\n - a\n - b\ntag: c").unwrap(); + let tags = normalize_tags(&mapping); + assert_eq!(tags, vec!["a", "b", "c"]); + } + + #[test] + fn test_normalize_tags_dedup_case_insensitive() { + let mapping: serde_yaml::Mapping = serde_yaml::from_str("tags: [Foo, foo]").unwrap(); + let tags = normalize_tags(&mapping); + assert_eq!(tags, vec!["Foo"]); + } + + #[test] + fn test_normalize_tags_nested() { + let mapping: serde_yaml::Mapping = + serde_yaml::from_str("tags:\n - project/work\n - important").unwrap(); + let tags = normalize_tags(&mapping); + assert_eq!(tags, vec!["project/work", "important"]); + } + + #[test] + fn test_split_anchor() { + assert_eq!(split_anchor("Note"), ("Note", None)); + assert_eq!(split_anchor("Note#Heading"), ("Note", Some("Heading"))); + assert_eq!(split_anchor("Note#^block-id"), ("Note", Some("^block-id"))); + assert_eq!(split_anchor("Note#"), ("Note#", None)); + } + + #[test] + fn test_is_dimension_spec() { + assert!(is_dimension_spec("500")); + assert!(is_dimension_spec("500x300")); + assert!(is_dimension_spec("100X200")); + assert!(!is_dimension_spec("")); + assert!(!is_dimension_spec("alt text")); + assert!(!is_dimension_spec("50px")); + } + + #[test] + fn test_convert_syntax_highlight() { + let re = Regex::new(r"==([^\n=]+?)==").unwrap(); + let comment_re = Regex::new(r"(?s)%%([\s\S]*?)%%").unwrap(); + let input = "This is ==important== text."; + let result = convert_syntax(input, &re, &comment_re); + assert_eq!(result, "This is important text."); + } + + #[test] + fn test_convert_syntax_comment() { + let re = Regex::new(r"==([^\n=]+?)==").unwrap(); + let comment_re = Regex::new(r"(?s)%%([\s\S]*?)%%").unwrap(); + let input = "Visible %%hidden note%% text"; + let result = convert_syntax(input, &re, &comment_re); + assert_eq!(result, "Visible text"); + } + + #[test] + fn test_convert_syntax_multiline_comment() { + let re = Regex::new(r"==([^\n=]+?)==").unwrap(); + let comment_re = Regex::new(r"(?s)%%([\s\S]*?)%%").unwrap(); + let input = "%%line one\nline two%%"; + let result = convert_syntax(input, &re, &comment_re); + assert_eq!(result, ""); + } + + #[test] + fn test_convert_syntax_code_block_protection() { + let re = Regex::new(r"==([^\n=]+?)==").unwrap(); + let comment_re = Regex::new(r"(?s)%%([\s\S]*?)%%").unwrap(); + let input = "```\n==not a highlight==\n```\n==real highlight=="; + let result = convert_syntax(input, &re, &comment_re); + assert!(result.contains("==not a highlight==")); + assert!(result.contains("real highlight")); + } + + #[test] + fn test_extract_heading_title() { + assert_eq!( + 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("Body without heading"), None); + assert_eq!(extract_heading_title("## Subheading"), None); + assert_eq!(extract_heading_title(""), None); + } + + #[test] + fn test_normalize_frontmatter_generates_id() { + let raw = "# Plain Note\n\nNo frontmatter."; + let (meta, body) = normalize_frontmatter(raw, Path::new("Plain Note.md")); + assert!(!meta.id.is_empty()); + assert_eq!(meta.title, "Plain Note"); + assert!(meta.tags.is_empty()); + assert!(!meta.pinned); + assert!(body.contains("# Plain Note")); + } + + #[test] + fn test_normalize_frontmatter_extracts_heading_title() { + let raw = "# My Real Title\n\nContent."; + let (meta, _) = normalize_frontmatter(raw, Path::new("note1.md")); + assert_eq!(meta.title, "My Real Title"); + } + + #[test] + fn test_normalize_frontmatter_preserves_frontmatter_title() { + let raw = "---\ntitle: Frontmatter Title\n---\n# Different Heading\n\nBody"; + let (meta, _) = normalize_frontmatter(raw, Path::new("file.md")); + assert_eq!(meta.title, "Frontmatter Title"); + } + + #[test] + fn test_normalize_frontmatter_tags_from_comma_string() { + let raw = "---\ntags: a, b, c\n---\n# Note\n\nBody"; + let (meta, _) = normalize_frontmatter(raw, Path::new("note.md")); + assert_eq!(meta.tags, vec!["a", "b", "c"]); + } + + #[test] + fn test_normalize_tags_strips_hash() { + let mapping: serde_yaml::Mapping = + serde_yaml::from_str("tags:\n - \"#hashed\"").unwrap(); + let tags = normalize_tags(&mapping); + assert_eq!(tags, vec!["hashed"]); + } + + #[test] + fn test_pathdiff_same_dir() { + assert_eq!(pathdiff("/a/b/c.md", "/a/b").unwrap(), "c.md"); + } + + #[test] + fn test_pathdiff_subdir() { + assert_eq!(pathdiff("/a/b/c.md", "/a").unwrap(), "b/c.md"); + } + + #[test] + fn test_pathdiff_parent_dir() { + assert_eq!(pathdiff("/a/c.md", "/a/b").unwrap(), "../c.md"); + } + + #[test] + fn test_rename_deprecated_alias() { + let raw = "---\nalias:\n - Name1\n---\n# Note\n\nBody"; + let result = rename_deprecated_properties(raw); + assert!(result.contains("aliases:")); + assert!(!result.contains("\nalias:")); + } + + #[test] + fn test_rename_deprecated_cssclass() { + let raw = "---\ncssclass: custom\n---\n# Note"; + let result = rename_deprecated_properties(raw); + assert!(result.contains("cssclasses:")); + assert!(!result.contains("\ncssclass:")); + } + + #[test] + fn test_rename_deprecated_no_override() { + let raw = "---\naliases:\n - A\nalias:\n - B\n---\n# Note"; + let result = rename_deprecated_properties(raw); + assert!(result.contains("aliases:")); + assert!(!result.contains("\nalias:")); + } + + #[test] + fn test_rename_deprecated_no_change_needed() { + let raw = "---\naliases: [A]\ncssclasses: [c]\n---\n# Note"; + let result = rename_deprecated_properties(raw); + assert_eq!(result, raw); + } + + #[test] + fn test_split_anchor_same_note() { + let (file, anchor) = split_anchor("#Heading"); + assert_eq!(file, ""); + assert_eq!(anchor, Some("Heading")); + } +} diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 8304fcb..4ea15eb 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -1,3 +1,4 @@ pub mod frontmatter; +pub mod import; pub mod operations; pub mod watcher; diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index 0315d6e..e6860b5 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -739,10 +739,16 @@ importResult = null; importError = null; - const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; error?: string }>('import-done', (event) => { + const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; frontmatter_normalized?: number; syntax_converted?: number; attachments_moved?: number; error?: string }>('import-done', (event) => { const data = event.payload; if (data.success) { - importResult = { files_converted: data.files_converted ?? 0, links_converted: data.links_converted ?? 0 }; + importResult = { + files_converted: data.files_converted ?? 0, + links_converted: data.links_converted ?? 0, + frontmatter_normalized: data.frontmatter_normalized ?? 0, + syntax_converted: data.syntax_converted ?? 0, + attachments_moved: data.attachments_moved ?? 0, + }; } else { importError = data.error ?? 'Import failed'; } @@ -1807,7 +1813,7 @@

Import from Obsidian

-

Convert Obsidian wiki-links (![[image.png]], [[note]]) to standard markdown links across all notes in the current vault. This expects that you have opened an Obsidian vault directory directly as your HelixNotes vault.

+

Fully converts an Obsidian vault to HelixNotes format: normalizes frontmatter (tags, IDs, dates, title headings), converts [[wiki-links]] to standard markdown links, converts ==highlights== and %%comments%%, and relocates attachments to .helixnotes/attachments/. Open the Obsidian vault directory as your HelixNotes vault first.

This modifies files in place. Make a backup before running! @@ -1826,12 +1832,21 @@ {/if} - {#if importResult} -
- - Converted {importResult.links_converted} links across {importResult.files_converted} files + {#if importResult} +
+ +
+ Import complete +
    + {#if importResult.frontmatter_normalized}
  • Normalized frontmatter in {importResult.frontmatter_normalized} notes
  • {/if} + {#if importResult.links_converted}
  • Converted {importResult.links_converted} wiki-links to markdown
  • {/if} + {#if importResult.syntax_converted}
  • Converted Obsidian syntax (highlights, comments) in {importResult.syntax_converted} notes
  • {/if} + {#if importResult.attachments_moved}
  • Moved {importResult.attachments_moved} attachments to .helixnotes/attachments/
  • {/if} + {#if !importResult.frontmatter_normalized && !importResult.links_converted && !importResult.syntax_converted && !importResult.attachments_moved}
  • No changes needed
  • {/if} +
- {/if} +
+ {/if} {#if importError}
@@ -2803,7 +2818,7 @@ .import-result { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; margin-top: 14px; padding: 10px 14px; @@ -2816,6 +2831,12 @@ color: #059669; } + .import-stats ul { + margin: 4px 0 0 0; + padding-left: 16px; + line-height: 1.6; + } + .import-result.error { background: color-mix(in srgb, #e11d48 10%, transparent); color: #e11d48; diff --git a/src/lib/types.ts b/src/lib/types.ts index e1d6697..e15be01 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -171,6 +171,9 @@ export interface FileEvent { export interface ImportResult { files_converted: number; links_converted: number; + frontmatter_normalized: number; + syntax_converted: number; + attachments_moved: number; } export interface VaultStats {