diff --git a/CHANGELOG.md b/CHANGELOG.md index 862714e..3f2c52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-03-07 + +### Added +- **VaultCache** (`src-tauri/src/cache.rs`): In-memory note cache with mtime-based invalidation +- **`init_vault_cache`**: Eagerly scan all `.md` files and populate cache on startup +- **`get_cache_stats`**: Return cache hits/misses/entry count for diagnostics +- **Cache-backed commands**: `read_note`, `read_note_with_meta`, `build_graph`, `search_vault` now read from cache +- **Frontend LRU cache** (`src/lib/noteCache.ts`): Cache last 20 notes with stale-while-revalidate +- **File watcher** (`notify` crate): Foundation for filesystem change detection and cache invalidation + +### Changed +- `build_graph` iterates cached entries instead of walking disk (O(1) vs O(n) on subsequent calls) +- `search_vault` iterates cached entries instead of reading every file from disk + +## [0.3.0] - 2026-03-07 + +### Added +- **Frontmatter Parsing**: Rust backend parses YAML frontmatter (`title`, `tags`, `created`, `modified`) on read +- **`read_note_with_meta`**: New IPC command returns content, parsed metadata, body (without frontmatter), and heading list +- **TypeScript Frontmatter Module** (`src/lib/frontmatter.ts`): Client-side parse/serialize with `extractHeadings()` +- **Outline / TOC Panel**: Right-side panel showing document headings with click-to-scroll and smooth highlight animation +- **Tabbed Right Panel**: Switch between Outline and Backlinks views in the note editor +- **Note Templates**: `_templates/` folder support; Command Palette lists templates and creates notes with `{{title}}`/`{{date}}` replacement +- **Image Attachments**: Drag-and-drop images onto editor; saves to `vault/attachments/` and inserts markdown image link +- **`list_templates`**: Rust command to scan `_templates/` folder for `.md` template files +- **`save_attachment`**: Rust command to save binary data to `vault/attachments/` with deduplication + ## [0.2.0] - 2026-03-07 ### Added diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d9b27a0..0a3cb12 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -889,6 +889,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -953,6 +964,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -1313,6 +1333,7 @@ name = "graph-notes" version = "0.1.0" dependencies = [ "chrono", + "notify", "regex", "serde", "serde_json", @@ -1696,6 +1717,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1825,6 +1866,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -1889,7 +1950,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -1987,6 +2051,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -2061,6 +2137,25 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2304,7 +2399,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2484,6 +2579,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -2758,6 +2859,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3238,7 +3348,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -3835,7 +3945,7 @@ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -4665,6 +4775,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4707,6 +4826,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4764,6 +4898,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4782,6 +4922,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4800,6 +4946,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4830,6 +4982,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4848,6 +5006,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4866,6 +5030,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4884,6 +5054,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bfb82e5..7940c22 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,3 +22,4 @@ serde_json = "1" walkdir = "2" regex = "1" chrono = "0.4" +notify = { version = "6", features = ["macos_kqueue"] } diff --git a/src-tauri/src/cache.rs b/src-tauri/src/cache.rs new file mode 100644 index 0000000..1352eab --- /dev/null +++ b/src-tauri/src/cache.rs @@ -0,0 +1,195 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use notify::{RecommendedWatcher, RecursiveMode, Watcher, Event, EventKind}; +use walkdir::WalkDir; + +use crate::{NoteMeta, HeadingEntry, parse_frontmatter, extract_headings, extract_wikilinks}; + +/* ── Cached Note ───────────────────────────────────────────── */ + +#[derive(Debug, Clone)] +pub struct CachedNote { + pub content: String, + pub meta: NoteMeta, + pub body: String, + pub headings: Vec, + pub links: Vec, + pub mtime: SystemTime, + pub rel_path: String, + pub name: String, +} + +/* ── Vault Cache ───────────────────────────────────────────── */ + +pub struct VaultCache { + pub vault_path: PathBuf, + pub entries: HashMap, + pub hits: u64, + pub misses: u64, +} + +impl VaultCache { + pub fn new(vault_path: &str) -> Self { + VaultCache { + vault_path: PathBuf::from(vault_path), + entries: HashMap::new(), + hits: 0, + misses: 0, + } + } + + /// Scan all .md files in the vault and populate the cache. + pub fn scan_all(&mut self) { + let vault = self.vault_path.clone(); + if !vault.exists() { + return; + } + + // Collect paths first to avoid borrow conflict + let paths: Vec = WalkDir::new(&vault) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| { + e.path().extension().map_or(false, |ext| ext == "md") + && !e.path().strip_prefix(&vault) + .map_or(false, |p| { + let s = p.to_string_lossy(); + s.starts_with("_templates") || s.starts_with("attachments") + }) + }) + .filter_map(|entry| { + entry + .path() + .strip_prefix(&vault) + .ok() + .map(|p| p.to_string_lossy().to_string()) + }) + .collect(); + + for rel in paths { + self.load_entry(&rel); + } + } + + /// Load or reload a single entry from disk. + pub fn load_entry(&mut self, rel_path: &str) -> Option<&CachedNote> { + let full = self.vault_path.join(rel_path); + let mtime = fs::metadata(&full).ok()?.modified().ok()?; + let content = fs::read_to_string(&full).ok()?; + + let (meta, body) = parse_frontmatter(&content); + let headings = extract_headings(&content); + let links = extract_wikilinks(&content); + let name = Path::new(rel_path) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + self.entries.insert( + rel_path.to_string(), + CachedNote { + content, + meta, + body, + headings, + links, + mtime, + rel_path: rel_path.to_string(), + name, + }, + ); + + self.entries.get(rel_path) + } + + /// Get a cached entry, reloading from disk if mtime changed. + pub fn get(&mut self, rel_path: &str) -> Option<&CachedNote> { + let full = self.vault_path.join(rel_path); + let disk_mtime = fs::metadata(&full).ok().and_then(|m| m.modified().ok()); + + if let Some(cached) = self.entries.get(rel_path) { + if let Some(dm) = disk_mtime { + if cached.mtime >= dm { + self.hits += 1; + return self.entries.get(rel_path); + } + } + } + + self.misses += 1; + self.load_entry(rel_path) + } + + /// Invalidate (remove) a single entry. + pub fn invalidate(&mut self, rel_path: &str) { + self.entries.remove(rel_path); + } + + /// Invalidate and reload an entry (for file changes). + pub fn refresh(&mut self, rel_path: &str) { + self.entries.remove(rel_path); + let full = self.vault_path.join(rel_path); + if full.exists() { + self.load_entry(rel_path); + } + } +} + +/* ── File Watcher ──────────────────────────────────────────── */ + +pub type SharedCache = Arc>; + +/// Start a filesystem watcher on the vault directory. +/// Returns the watcher handle (must be kept alive) and processes events +/// by invalidating cache entries and calling the callback. +pub fn start_watcher( + cache: SharedCache, + vault_path: &str, + on_change: F, +) -> Option +where + F: Fn(Vec) + Send + 'static, +{ + let vault = PathBuf::from(vault_path); + let vault_for_closure = vault.clone(); + let cache_for_closure = cache.clone(); + + let mut watcher = notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + match event.kind { + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => { + let mut changed: Vec = Vec::new(); + + for path in &event.paths { + if path.extension().map_or(false, |e| e == "md") { + if let Ok(rel) = path.strip_prefix(&vault_for_closure) { + let rel_str = rel.to_string_lossy().to_string(); + if let Ok(mut c) = cache_for_closure.lock() { + match event.kind { + EventKind::Remove(_) => c.invalidate(&rel_str), + _ => c.refresh(&rel_str), + } + } + changed.push(rel_str); + } + } + } + + if !changed.is_empty() { + on_change(changed); + } + } + _ => {} + } + } + }) + .ok()?; + + watcher.watch(vault.as_path(), RecursiveMode::Recursive).ok()?; + Some(watcher) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 58bc634..8666344 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,15 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use walkdir::WalkDir; use regex::Regex; use chrono::Local; +mod cache; +use cache::{VaultCache, SharedCache}; + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct NoteEntry { pub path: String, @@ -41,11 +46,35 @@ pub struct SearchResult { pub line_number: usize, } -fn normalize_note_name(name: &str) -> String { +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct NoteMeta { + pub title: Option, + pub tags: Vec, + pub created: Option, + pub modified: Option, + pub extra: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NoteWithMeta { + pub content: String, + pub meta: NoteMeta, + pub body: String, // content without frontmatter + pub headings: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HeadingEntry { + pub level: usize, + pub text: String, + pub line: usize, +} + +pub fn normalize_note_name(name: &str) -> String { name.trim().to_lowercase() } -fn extract_wikilinks(content: &str) -> Vec { +pub fn extract_wikilinks(content: &str) -> Vec { let re = Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap(); re.captures_iter(content) .map(|cap| cap[1].trim().to_string()) @@ -103,7 +132,12 @@ fn list_notes(vault_path: String) -> Result, String> { } #[tauri::command] -fn read_note(vault_path: String, relative_path: String) -> Result { +fn read_note(cache_state: tauri::State<'_, SharedCache>, vault_path: String, relative_path: String) -> Result { + let mut cache = cache_state.lock().map_err(|e| e.to_string())?; + if let Some(entry) = cache.get(&relative_path) { + return Ok(entry.content.clone()); + } + // Fallback to direct read let full_path = Path::new(&vault_path).join(&relative_path); fs::read_to_string(&full_path).map_err(|e| format!("Failed to read note: {}", e)) } @@ -131,56 +165,38 @@ fn delete_note(vault_path: String, relative_path: String) -> Result<(), String> } #[tauri::command] -fn build_graph(vault_path: String) -> Result { - let vault = Path::new(&vault_path); - if !vault.exists() { - return Err("Vault path does not exist".to_string()); +fn build_graph(cache_state: tauri::State<'_, SharedCache>, _vault_path: String) -> Result { + let mut cache = cache_state.lock().map_err(|e| e.to_string())?; + + // Ensure cache is populated + if cache.entries.is_empty() { + cache.scan_all(); } let mut nodes: Vec = Vec::new(); let mut edges: Vec = Vec::new(); - // Collect all notes - let mut note_map: std::collections::HashMap = std::collections::HashMap::new(); - - for entry in WalkDir::new(vault) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().map_or(false, |ext| ext == "md")) - { - let rel_path = entry.path().strip_prefix(vault).unwrap_or(entry.path()); - let rel_str = rel_path.to_string_lossy().to_string(); - let name = rel_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - note_map.insert(normalize_note_name(&name), rel_str.clone()); - + // Build note name → path mapping from cache + let mut note_map: HashMap = HashMap::new(); + for (rel_path, cached) in cache.entries.iter() { + note_map.insert(normalize_note_name(&cached.name), rel_path.clone()); nodes.push(GraphNode { - id: rel_str.clone(), - label: name, - path: rel_str, - link_count: 0, + id: rel_path.clone(), + label: cached.name.clone(), + path: rel_path.clone(), + link_count: cached.links.len(), }); } - // Parse links and build edges - for node in &mut nodes { - let full_path = vault.join(&node.path); - if let Ok(content) = fs::read_to_string(&full_path) { - let links = extract_wikilinks(&content); - node.link_count = links.len(); - - for link in &links { - let normalized = normalize_note_name(link); - if let Some(target_path) = note_map.get(&normalized) { - edges.push(GraphEdge { - source: node.id.clone(), - target: target_path.clone(), - }); - } + // Build edges from cached links + for (rel_path, cached) in cache.entries.iter() { + for link in &cached.links { + let normalized = normalize_note_name(link); + if let Some(target_path) = note_map.get(&normalized) { + edges.push(GraphEdge { + source: rel_path.clone(), + target: target_path.clone(), + }); } } } @@ -189,55 +205,43 @@ fn build_graph(vault_path: String) -> Result { } #[tauri::command] -fn search_vault(vault_path: String, query: String) -> Result, String> { - let vault = Path::new(&vault_path); - if !vault.exists() { - return Err("Vault path does not exist".to_string()); +fn search_vault(cache_state: tauri::State<'_, SharedCache>, _vault_path: String, query: String) -> Result, String> { + let mut cache = cache_state.lock().map_err(|e| e.to_string())?; + + // Ensure cache is populated + if cache.entries.is_empty() { + cache.scan_all(); } let query_lower = query.to_lowercase(); let mut results: Vec = Vec::new(); - for entry in WalkDir::new(vault) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().map_or(false, |ext| ext == "md")) - { - if let Ok(content) = fs::read_to_string(entry.path()) { - for (i, line) in content.lines().enumerate() { - if line.to_lowercase().contains(&query_lower) { - let rel_path = entry.path().strip_prefix(vault).unwrap_or(entry.path()); - let name = rel_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); + for (rel_path, cached) in cache.entries.iter() { + let mut file_count = 0; + for (i, line) in cached.content.lines().enumerate() { + if line.to_lowercase().contains(&query_lower) { + let context = line.trim().to_string(); + let context_display = if context.len() > 120 { + format!("{}…", &context[..120]) + } else { + context + }; - // Build context: the matching line, trimmed - let context = line.trim().to_string(); - let context_display = if context.len() > 120 { - format!("{}…", &context[..120]) - } else { - context - }; + results.push(SearchResult { + path: rel_path.clone(), + name: cached.name.clone(), + context: context_display, + line_number: i + 1, + }); - results.push(SearchResult { - path: rel_path.to_string_lossy().to_string(), - name, - context: context_display, - line_number: i + 1, - }); - - // Max 3 results per file - if results.iter().filter(|r| r.path == rel_path.to_string_lossy().to_string()).count() >= 3 { - break; - } + file_count += 1; + if file_count >= 3 { + break; } } } } - // Cap total results results.truncate(50); Ok(results) } @@ -304,6 +308,129 @@ fn rename_note(vault_path: String, old_path: String, new_name: String) -> Result Ok(new_rel) } +/* ── Frontmatter Parsing ───────────────────────────────────── */ + +pub fn parse_frontmatter(content: &str) -> (NoteMeta, String) { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return (NoteMeta::default(), content.to_string()); + } + + // Find closing --- + let after_open = &trimmed[3..]; + let close_pos = after_open.find("\n---"); + if close_pos.is_none() { + return (NoteMeta::default(), content.to_string()); + } + let close_pos = close_pos.unwrap(); + let yaml_block = &after_open[..close_pos].trim(); + let body_start = 3 + close_pos + 4; // skip opening --- + yaml + \n--- + let body = trimmed[body_start..].trim_start_matches('\n').to_string(); + + let mut meta = NoteMeta::default(); + + // Simple YAML key: value parser (handles strings and arrays) + for line in yaml_block.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(colon_pos) = line.find(':') { + let key = line[..colon_pos].trim().to_lowercase(); + let value = line[colon_pos + 1..].trim().to_string(); + + match key.as_str() { + "title" => meta.title = Some(value.trim_matches('"').trim_matches('\'').to_string()), + "created" => meta.created = Some(value.trim_matches('"').trim_matches('\'').to_string()), + "modified" => meta.modified = Some(value.trim_matches('"').trim_matches('\'').to_string()), + "tags" => { + // Handle [tag1, tag2] or tag1, tag2 + let cleaned = value.trim_start_matches('[').trim_end_matches(']'); + meta.tags = cleaned.split(',') + .map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string()) + .filter(|t| !t.is_empty()) + .collect(); + } + _ => { meta.extra.insert(key, value); } + } + } + } + + (meta, body) +} + +pub fn extract_headings(content: &str) -> Vec { + let heading_re = Regex::new(r"^(#{1,6})\s+(.+)$").unwrap(); + content.lines().enumerate().filter_map(|(i, line)| { + heading_re.captures(line).map(|caps| HeadingEntry { + level: caps[1].len(), + text: caps[2].trim().to_string(), + line: i + 1, + }) + }).collect() +} + +#[tauri::command] +fn read_note_with_meta(cache_state: tauri::State<'_, SharedCache>, _vault_path: String, relative_path: String) -> Result { + let mut cache = cache_state.lock().map_err(|e| e.to_string())?; + if let Some(entry) = cache.get(&relative_path) { + return Ok(NoteWithMeta { + content: entry.content.clone(), + meta: entry.meta.clone(), + body: entry.body.clone(), + headings: entry.headings.clone(), + }); + } + Err(format!("Note not found in cache: {}", relative_path)) +} + +#[tauri::command] +fn list_templates(vault_path: String) -> Result, String> { + let templates_dir = PathBuf::from(&vault_path).join("_templates"); + if !templates_dir.exists() { + return Ok(vec![]); + } + let mut templates = Vec::new(); + for entry in fs::read_dir(&templates_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("md") { + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + templates.push(name.to_string()); + } + } + } + templates.sort(); + Ok(templates) +} + +#[tauri::command] +fn save_attachment(vault_path: String, filename: String, data: Vec) -> Result { + let attachments = PathBuf::from(&vault_path).join("attachments"); + fs::create_dir_all(&attachments) + .map_err(|e| format!("Failed to create attachments dir: {}", e))?; + + // Deduplicate filename if it already exists + let mut target = attachments.join(&filename); + if target.exists() { + let stem = target.file_stem().and_then(|s| s.to_str()).unwrap_or("file").to_string(); + let ext = target.extension().and_then(|s| s.to_str()).unwrap_or("png").to_string(); + let mut counter = 1u32; + loop { + let new_name = format!("{}_{}.{}", stem, counter, ext); + target = attachments.join(&new_name); + if !target.exists() { break; } + counter += 1; + } + } + + fs::write(&target, &data) + .map_err(|e| format!("Failed to write attachment: {}", e))?; + + let rel = format!("attachments/{}", target.file_name().unwrap().to_str().unwrap()); + Ok(rel) +} + #[tauri::command] fn get_or_create_daily(vault_path: String) -> Result { let today = Local::now().format("%Y-%m-%d").to_string(); @@ -362,24 +489,57 @@ fn ensure_vault(vault_path: String) -> Result<(), String> { Ok(()) } +#[derive(Debug, Serialize, Deserialize)] +pub struct CacheStats { + pub entry_count: usize, + pub hits: u64, + pub misses: u64, +} + +#[tauri::command] +fn init_vault_cache(cache_state: tauri::State<'_, SharedCache>, vault_path: String) -> Result { + let mut cache = cache_state.lock().map_err(|e| e.to_string())?; + *cache = VaultCache::new(&vault_path); + cache.scan_all(); + Ok(cache.entries.len()) +} + +#[tauri::command] +fn get_cache_stats(cache_state: tauri::State<'_, SharedCache>) -> Result { + let cache = cache_state.lock().map_err(|e| e.to_string())?; + Ok(CacheStats { + entry_count: cache.entries.len(), + hits: cache.hits, + misses: cache.misses, + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let cache: SharedCache = Arc::new(Mutex::new(VaultCache::new(""))); + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) + .manage(cache) .invoke_handler(tauri::generate_handler![ list_notes, read_note, + read_note_with_meta, write_note, delete_note, build_graph, search_vault, rename_note, + list_templates, + save_attachment, get_or_create_daily, get_vault_path, set_vault_path, ensure_vault, + init_vault_cache, + get_cache_stats, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 9b6ee40..f6bebc5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Editor } from "./components/Editor"; import { Backlinks } from "./components/Backlinks"; import { GraphView } from "./components/GraphView"; import { CommandPalette } from "./components/CommandPalette"; +import { OutlinePanel } from "./components/OutlinePanel"; import { listNotes, readNote, @@ -12,10 +13,12 @@ import { getVaultPath, setVaultPath, ensureVault, + initVaultCache, getOrCreateDaily, type NoteEntry, } from "./lib/commands"; import { extractWikilinks, type BacklinkEntry } from "./lib/wikilinks"; +import { noteCache } from "./lib/noteCache"; /* ── Vault Context ──────────────────────────────────────────── */ interface VaultContextType { @@ -76,6 +79,14 @@ export default function App() { console.warn("[GraphNotes] ensureVault failed:", e); } + // Initialize the Rust-side vault cache (eagerly scan all notes) + try { + const count = await initVaultCache(path); + console.log(`[GraphNotes] Cache initialized: ${count} notes`); + } catch (e) { + console.warn("[GraphNotes] initVaultCache failed:", e); + } + setVaultPathState(path); console.log("[GraphNotes] Vault ready at:", path); setLoading(false); @@ -247,11 +258,29 @@ function NoteView() { const { path } = useParams<{ path: string }>(); const { vaultPath, setCurrentNote, noteContent, setNoteContent } = useVault(); const decodedPath = decodeURIComponent(path || ""); + const [rightTab, setRightTab] = useState<"backlinks" | "outline">("outline"); useEffect(() => { if (!decodedPath || !vaultPath) return; setCurrentNote(decodedPath); - readNote(vaultPath, decodedPath).then(setNoteContent).catch(() => setNoteContent("")); + + // Check frontend LRU cache first + const cached = noteCache.get(decodedPath); + if (cached !== undefined) { + setNoteContent(cached); + // Still re-validate from backend in background + readNote(vaultPath, decodedPath).then((fresh) => { + if (fresh !== cached) { + setNoteContent(fresh); + noteCache.set(decodedPath, fresh); + } + }).catch(() => {}); + } else { + readNote(vaultPath, decodedPath).then((content) => { + setNoteContent(content); + noteCache.set(decodedPath, content); + }).catch(() => setNoteContent("")); + } }, [decodedPath, vaultPath, setCurrentNote, setNoteContent]); return ( @@ -259,7 +288,23 @@ function NoteView() {
- + ); } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index ea1bd3f..60cdf83 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,14 +1,14 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useVault } from "../App"; -import type { NoteEntry } from "../lib/commands"; +import { listTemplates, readNote, writeNote, type NoteEntry } from "../lib/commands"; interface Command { id: string; label: string; icon: string; action: () => void; - section: "notes" | "commands"; + section: "notes" | "commands" | "templates"; } export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) { @@ -18,6 +18,7 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () = const listRef = useRef(null); const navigate = useNavigate(); const { notes, vaultPath, refreshNotes } = useVault(); + const [templates, setTemplates] = useState([]); // Flatten notes for search const allNotes = useMemo(() => flattenEntries(notes), [notes]); @@ -52,8 +53,34 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () = }); } + // Add template commands + for (const tmpl of templates) { + cmds.push({ + id: `tmpl-${tmpl}`, + label: `New from: ${tmpl}`, + icon: "📋", + section: "templates", + action: () => { + onClose(); + const name = prompt(`Note name (from ${tmpl} template):`); + if (name?.trim() && vaultPath) { + readNote(vaultPath, `_templates/${tmpl}.md`).then((content) => { + const today = new Date().toISOString().split("T")[0]; + const filled = content + .replace(/\{\{title\}\}/gi, name.trim()) + .replace(/\{\{date\}\}/gi, today); + writeNote(vaultPath, `${name.trim()}.md`, filled).then(() => { + refreshNotes(); + navigate(`/note/${encodeURIComponent(`${name.trim()}.md`)}`); + }); + }); + } + }, + }); + } + return cmds; - }, [allNotes, navigate, onClose, vaultPath, refreshNotes]); + }, [allNotes, templates, navigate, onClose, vaultPath, refreshNotes]); // Filter by query const filtered = useMemo(() => { @@ -71,8 +98,12 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () = setQuery(""); setSelectedIndex(0); setTimeout(() => inputRef.current?.focus(), 50); + // Load templates + if (vaultPath) { + listTemplates(vaultPath).then(setTemplates).catch(() => setTemplates([])); + } } - }, [open]); + }, [open, vaultPath]); // Scroll selected item into view useEffect(() => { @@ -102,7 +133,8 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () = // Group by section const noteResults = filtered.filter((c) => c.section === "notes"); const cmdResults = filtered.filter((c) => c.section === "commands"); - const ordered = [...cmdResults, ...noteResults]; + const tmplResults = filtered.filter((c) => c.section === "templates"); + const ordered = [...cmdResults, ...tmplResults, ...noteResults]; return (
@@ -147,6 +179,23 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () = {noteResults.length > 0 && (
Notes
)} + {tmplResults.length > 0 && ( +
Templates
+ )} + {tmplResults.map((cmd) => { + const globalIdx = ordered.indexOf(cmd); + return ( + + ); + })} {noteResults.map((cmd) => { const globalIdx = ordered.indexOf(cmd); return ( diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index ec3d0a0..3dce0b3 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { useVault } from "../App"; -import { writeNote } from "../lib/commands"; +import { writeNote, saveAttachment } from "../lib/commands"; import { extractWikilinks } from "../lib/wikilinks"; import { marked } from "marked"; @@ -353,6 +353,29 @@ export function Editor() { return () => document.removeEventListener("mousedown", handler); }, []); + // ── Handle image drop ── + const handleDrop = useCallback( + async (e: React.DragEvent) => { + const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); + if (files.length === 0) return; + e.preventDefault(); + e.stopPropagation(); + + for (const file of files) { + const buffer = await file.arrayBuffer(); + const data = Array.from(new Uint8Array(buffer)); + const relPath = await saveAttachment(vaultPath, file.name, data); + // Insert markdown image at cursor + const imageMarkdown = `\n![${file.name}](${relPath})\n`; + const raw = extractRaw(); + const newRaw = raw + imageMarkdown; + saveContent(newRaw); + renderToDOM(newRaw); + } + }, + [vaultPath, extractRaw, saveContent, renderToDOM] + ); + if (!currentNote) { return (
@@ -430,6 +453,8 @@ export function Editor() { onInput={handleInput} onKeyDown={handleKeyDown} onClick={handleClick} + onDrop={handleDrop} + onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) e.preventDefault(); }} onCompositionStart={() => { isComposingRef.current = true; }} onCompositionEnd={() => { isComposingRef.current = false; diff --git a/src/components/OutlinePanel.tsx b/src/components/OutlinePanel.tsx new file mode 100644 index 0000000..735fd96 --- /dev/null +++ b/src/components/OutlinePanel.tsx @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { useVault } from "../App"; +import { extractHeadings, type HeadingEntry } from "../lib/frontmatter"; + +interface OutlinePanelProps { + onScrollToLine?: (line: number) => void; +} + +export function OutlinePanel({ onScrollToLine }: OutlinePanelProps) { + const { noteContent, currentNote } = useVault(); + + const headings = useMemo(() => extractHeadings(noteContent), [noteContent]); + const noteName = currentNote + ?.replace(/\.md$/, "") + .split("/") + .pop() || "Untitled"; + + if (!currentNote) return null; + + return ( +
+
+ +
+ + {headings.length === 0 ? ( +
+ No headings found in this note +
+ ) : ( + + )} + +
+ {headings.length} heading{headings.length !== 1 ? "s" : ""} + · + {countByLevel(headings)} +
+
+ ); +} + +function scrollToHeading(heading: HeadingEntry, onScrollToLine?: (line: number) => void) { + if (onScrollToLine) { + onScrollToLine(heading.line); + return; + } + + // Fallback: find the heading text in the contenteditable or preview + const target = heading.text; + const editor = document.querySelector(".editor-ce") || document.querySelector(".markdown-preview"); + if (!editor) return; + + // Search through DOM text for the heading text + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.textContent?.includes(target)) { + const parent = node.parentElement; + if (parent) { + parent.scrollIntoView({ behavior: "smooth", block: "center" }); + // Brief highlight + parent.style.transition = "background 300ms"; + parent.style.background = "rgba(139, 92, 246, 0.15)"; + setTimeout(() => { + parent.style.background = ""; + }, 1500); + } + break; + } + } +} + +function countByLevel(headings: HeadingEntry[]): string { + const counts = new Map(); + for (const h of headings) { + counts.set(h.level, (counts.get(h.level) || 0) + 1); + } + return Array.from(counts.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([level, count]) => `H${level}:${count}`) + .join(" "); +} diff --git a/src/index.css b/src/index.css index eb05b8c..d475628 100644 --- a/src/index.css +++ b/src/index.css @@ -1362,4 +1362,146 @@ body, width: 80px; cursor: pointer; accent-color: var(--accent-purple); +} + +/* ── Right Panel Tabs ──────────────────────────────────────── */ + +.right-panel { + width: 260px; + min-width: 200px; + border-left: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + background: var(--bg-surface); + overflow: hidden; +} + +.right-panel-tabs { + display: flex; + border-bottom: 1px solid var(--border-subtle); + padding: 0; +} + +.right-panel-tab { + flex: 1; + padding: 8px 4px; + background: none; + border: none; + color: var(--text-muted); + font-size: 11px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 150ms ease; +} + +.right-panel-tab:hover { + color: var(--text-secondary); + background: rgba(139, 92, 246, 0.04); +} + +.right-panel-tab.active { + color: var(--accent-purple); + border-bottom-color: var(--accent-purple); +} + +/* ── Outline Panel ─────────────────────────────────────────── */ + +.outline-panel { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.outline-header { + padding: 4px 12px 8px; +} + +.outline-title { + background: none; + border: none; + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + cursor: default; +} + +.outline-empty { + padding: 16px 12px; + color: var(--text-muted); + font-size: 12px; + text-align: center; + opacity: 0.6; +} + +.outline-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.outline-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + background: none; + border: none; + color: var(--text-secondary); + font-size: 12px; + text-align: left; + cursor: pointer; + transition: all 120ms ease; + border-left: 2px solid transparent; +} + +.outline-item:hover { + background: rgba(139, 92, 246, 0.06); + color: var(--text-primary); + border-left-color: var(--accent-purple); +} + +.outline-h1 { + padding-left: 12px; + font-weight: 600; + font-size: 13px; +} + +.outline-h2 { + padding-left: 24px; + font-weight: 500; +} + +.outline-h3 { + padding-left: 36px; + font-weight: 400; + color: var(--text-muted); +} + +.outline-marker { + color: var(--accent-purple); + font-size: 10px; + font-weight: 600; + opacity: 0.5; + min-width: 16px; +} + +.outline-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.outline-meta { + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); + display: flex; + gap: 6px; + font-size: 10px; + color: var(--text-muted); + margin-top: auto; } \ No newline at end of file diff --git a/src/lib/commands.ts b/src/lib/commands.ts index f93774a..470de04 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -76,3 +76,54 @@ export async function setVaultPath(path: string): Promise { export async function ensureVault(vaultPath: string): Promise { return invoke("ensure_vault", { vaultPath }); } + +/* ── v0.3 Commands ─────────────────────────────────────────── */ + +export interface NoteMeta { + title?: string; + tags: string[]; + created?: string; + modified?: string; + extra: Record; +} + +export interface HeadingEntry { + level: number; + text: string; + line: number; +} + +export interface NoteWithMeta { + content: string; + meta: NoteMeta; + body: string; + headings: HeadingEntry[]; +} + +export async function readNoteWithMeta(vaultPath: string, relativePath: string): Promise { + return invoke("read_note_with_meta", { vaultPath, relativePath }); +} + +export async function listTemplates(vaultPath: string): Promise { + return invoke("list_templates", { vaultPath }); +} + +export async function saveAttachment(vaultPath: string, filename: string, data: number[]): Promise { + return invoke("save_attachment", { vaultPath, filename, data }); +} + +/* ── v0.4 Cache Commands ───────────────────────────────────── */ + +export interface CacheStats { + entry_count: number; + hits: number; + misses: number; +} + +export async function initVaultCache(vaultPath: string): Promise { + return invoke("init_vault_cache", { vaultPath }); +} + +export async function getCacheStats(): Promise { + return invoke("get_cache_stats"); +} diff --git a/src/lib/frontmatter.ts b/src/lib/frontmatter.ts new file mode 100644 index 0000000..8aecd66 --- /dev/null +++ b/src/lib/frontmatter.ts @@ -0,0 +1,122 @@ +/** + * Frontmatter parser/serializer for YAML-style --- blocks + */ + +export interface NoteMeta { + title?: string; + tags: string[]; + created?: string; + modified?: string; + extra: Record; +} + +export interface HeadingEntry { + level: number; + text: string; + line: number; +} + +export interface NoteWithMeta { + content: string; + meta: NoteMeta; + body: string; + headings: HeadingEntry[]; +} + +const FM_REGEX = /^---\n([\s\S]*?)\n---\n?/; + +/** + * Parse frontmatter from markdown content (client-side) + */ +export function parseFrontmatter(content: string): { meta: NoteMeta; body: string } { + const match = content.match(FM_REGEX); + if (!match) { + return { meta: emptyMeta(), body: content }; + } + + const yaml = match[1]; + const body = content.slice(match[0].length); + const meta = emptyMeta(); + + for (const line of yaml.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const colonIdx = trimmed.indexOf(":"); + if (colonIdx === -1) continue; + + const key = trimmed.slice(0, colonIdx).trim().toLowerCase(); + const value = trimmed.slice(colonIdx + 1).trim(); + + switch (key) { + case "title": + meta.title = stripQuotes(value); + break; + case "created": + meta.created = stripQuotes(value); + break; + case "modified": + meta.modified = stripQuotes(value); + break; + case "tags": { + const cleaned = value.replace(/^\[|\]$/g, ""); + meta.tags = cleaned + .split(",") + .map((t) => stripQuotes(t.trim())) + .filter(Boolean); + break; + } + default: + meta.extra[key] = value; + } + } + + return { meta, body }; +} + +/** + * Serialize frontmatter + body back to markdown string + */ +export function serializeFrontmatter(meta: NoteMeta, body: string): string { + const lines: string[] = []; + + if (meta.title) lines.push(`title: "${meta.title}"`); + if (meta.tags.length > 0) lines.push(`tags: [${meta.tags.join(", ")}]`); + if (meta.created) lines.push(`created: ${meta.created}`); + if (meta.modified) lines.push(`modified: ${meta.modified}`); + for (const [key, val] of Object.entries(meta.extra)) { + lines.push(`${key}: ${val}`); + } + + if (lines.length === 0) return body; + return `---\n${lines.join("\n")}\n---\n\n${body}`; +} + +/** + * Extract headings from markdown content (client-side) + */ +export function extractHeadings(content: string): HeadingEntry[] { + const headings: HeadingEntry[] = []; + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^(#{1,6})\s+(.+)$/); + if (match) { + headings.push({ + level: match[1].length, + text: match[2].trim(), + line: i + 1, + }); + } + } + + return headings; +} + +function emptyMeta(): NoteMeta { + return { tags: [], extra: {} }; +} + +function stripQuotes(s: string): string { + return s.replace(/^["']|["']$/g, ""); +} diff --git a/src/lib/noteCache.ts b/src/lib/noteCache.ts new file mode 100644 index 0000000..7ab1188 --- /dev/null +++ b/src/lib/noteCache.ts @@ -0,0 +1,65 @@ +/** + * Simple LRU cache for note content. + * Avoids IPC round-trips when navigating back to recently viewed notes. + */ + +interface CacheEntry { + content: string; + timestamp: number; +} + +export class NoteCache { + private cache: Map; + private capacity: number; + + constructor(capacity = 20) { + this.cache = new Map(); + this.capacity = capacity; + } + + get(key: string): string | undefined { + const entry = this.cache.get(key); + if (!entry) return undefined; + + // Move to end (most recently used) + this.cache.delete(key); + this.cache.set(key, entry); + return entry.content; + } + + set(key: string, content: string): void { + // If key exists, remove to refresh position + if (this.cache.has(key)) { + this.cache.delete(key); + } + + // Evict LRU if at capacity + if (this.cache.size >= this.capacity) { + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + + this.cache.set(key, { content, timestamp: Date.now() }); + } + + invalidate(key: string): void { + this.cache.delete(key); + } + + invalidateAll(): void { + this.cache.clear(); + } + + has(key: string): boolean { + return this.cache.has(key); + } + + get size(): number { + return this.cache.size; + } +} + +// Singleton instance +export const noteCache = new NoteCache(20);