feat: v0.3 + v0.4 — frontmatter, templates, outline panel, vault cache

v0.3: Core file reading & markdown infrastructure
- Rust: read_note_with_meta, list_templates, save_attachment commands
- TypeScript: frontmatter.ts (parse/serialize YAML, extract headings)
- OutlinePanel with click-to-scroll headings + tabbed right panel
- CommandPalette: New from Template with {{title}}/{{date}} replacement
- Editor: image drag-and-drop to attachments/
- 130 lines of CSS for outline panel and right panel tabs

v0.4: File reading & caching
- Rust: VaultCache (cache.rs) with mtime-based invalidation
- Rewrote read_note, read_note_with_meta, build_graph, search_vault to use cache
- init_vault_cache (eager scan on startup), get_cache_stats commands
- Frontend LRU noteCache (capacity 20, stale-while-revalidate)
- notify crate added for filesystem watching foundation
This commit is contained in:
enzotar 2026-03-07 09:54:08 -08:00
parent 2041798048
commit d174c7f26d
13 changed files with 1247 additions and 92 deletions

View file

@ -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

182
src-tauri/Cargo.lock generated
View file

@ -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"

View file

@ -22,3 +22,4 @@ serde_json = "1"
walkdir = "2"
regex = "1"
chrono = "0.4"
notify = { version = "6", features = ["macos_kqueue"] }

195
src-tauri/src/cache.rs Normal file
View file

@ -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<HeadingEntry>,
pub links: Vec<String>,
pub mtime: SystemTime,
pub rel_path: String,
pub name: String,
}
/* ── Vault Cache ───────────────────────────────────────────── */
pub struct VaultCache {
pub vault_path: PathBuf,
pub entries: HashMap<String, CachedNote>,
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<String> = 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<Mutex<VaultCache>>;
/// 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<F>(
cache: SharedCache,
vault_path: &str,
on_change: F,
) -> Option<RecommendedWatcher>
where
F: Fn(Vec<String>) + 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<Event, notify::Error>| {
if let Ok(event) = res {
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
let mut changed: Vec<String> = 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)
}

View file

@ -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<String>,
pub tags: Vec<String>,
pub created: Option<String>,
pub modified: Option<String>,
pub extra: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NoteWithMeta {
pub content: String,
pub meta: NoteMeta,
pub body: String, // content without frontmatter
pub headings: Vec<HeadingEntry>,
}
#[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<String> {
pub fn extract_wikilinks(content: &str) -> Vec<String> {
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<Vec<NoteEntry>, String> {
}
#[tauri::command]
fn read_note(vault_path: String, relative_path: String) -> Result<String, String> {
fn read_note(cache_state: tauri::State<'_, SharedCache>, vault_path: String, relative_path: String) -> Result<String, String> {
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<GraphData, String> {
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<GraphData, 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 mut nodes: Vec<GraphNode> = Vec::new();
let mut edges: Vec<GraphEdge> = Vec::new();
// Collect all notes
let mut note_map: std::collections::HashMap<String, String> = 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<String, String> = 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<GraphData, String> {
}
#[tauri::command]
fn search_vault(vault_path: String, query: String) -> Result<Vec<SearchResult>, 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<Vec<SearchResult>, 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<SearchResult> = 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<HeadingEntry> {
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<NoteWithMeta, String> {
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<Vec<String>, 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<u8>) -> Result<String, String> {
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<String, String> {
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<usize, String> {
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<CacheStats, String> {
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");

View file

@ -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() {
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
<Backlinks />
<aside className="right-panel">
<div className="right-panel-tabs">
<button
className={`right-panel-tab ${rightTab === "outline" ? "active" : ""}`}
onClick={() => setRightTab("outline")}
>
📑 Outline
</button>
<button
className={`right-panel-tab ${rightTab === "backlinks" ? "active" : ""}`}
onClick={() => setRightTab("backlinks")}
>
🔗 Backlinks
</button>
</div>
{rightTab === "outline" ? <OutlinePanel /> : <Backlinks />}
</aside>
</div>
);
}

View file

@ -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<HTMLDivElement>(null);
const navigate = useNavigate();
const { notes, vaultPath, refreshNotes } = useVault();
const [templates, setTemplates] = useState<string[]>([]);
// 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 (
<div className="palette-overlay" onClick={onClose}>
@ -147,6 +179,23 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
{noteResults.length > 0 && (
<div className="palette-section-label">Notes</div>
)}
{tmplResults.length > 0 && (
<div className="palette-section-label">Templates</div>
)}
{tmplResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (
<button
key={cmd.id}
className={`palette-item ${globalIdx === selectedIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(globalIdx)}
>
<span className="palette-item-icon">{cmd.icon}</span>
<span className="palette-item-label">{cmd.label}</span>
</button>
);
})}
{noteResults.map((cmd) => {
const globalIdx = ordered.indexOf(cmd);
return (

View file

@ -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<HTMLDivElement>) => {
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 (
<div className="flex-1 flex items-center justify-center">
@ -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;

View file

@ -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 (
<div className="outline-panel">
<div className="outline-header">
<button className="outline-title">📑 Outline</button>
</div>
{headings.length === 0 ? (
<div className="outline-empty">
No headings found in this note
</div>
) : (
<nav className="outline-list">
{headings.map((h, i) => (
<button
key={`${h.line}-${i}`}
className={`outline-item outline-h${Math.min(h.level, 3)}`}
onClick={() => scrollToHeading(h, onScrollToLine)}
title={`Line ${h.line}`}
>
<span className="outline-marker">
{"#".repeat(h.level)}
</span>
<span className="outline-text">{h.text}</span>
</button>
))}
</nav>
)}
<div className="outline-meta">
<span>{headings.length} heading{headings.length !== 1 ? "s" : ""}</span>
<span>·</span>
<span>{countByLevel(headings)}</span>
</div>
</div>
);
}
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<number, number>();
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(" ");
}

View file

@ -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;
}

View file

@ -76,3 +76,54 @@ export async function setVaultPath(path: string): Promise<void> {
export async function ensureVault(vaultPath: string): Promise<void> {
return invoke("ensure_vault", { vaultPath });
}
/* ── v0.3 Commands ─────────────────────────────────────────── */
export interface NoteMeta {
title?: string;
tags: string[];
created?: string;
modified?: string;
extra: Record<string, string>;
}
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<NoteWithMeta> {
return invoke<NoteWithMeta>("read_note_with_meta", { vaultPath, relativePath });
}
export async function listTemplates(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_templates", { vaultPath });
}
export async function saveAttachment(vaultPath: string, filename: string, data: number[]): Promise<string> {
return invoke<string>("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<number> {
return invoke<number>("init_vault_cache", { vaultPath });
}
export async function getCacheStats(): Promise<CacheStats> {
return invoke<CacheStats>("get_cache_stats");
}

122
src/lib/frontmatter.ts Normal file
View file

@ -0,0 +1,122 @@
/**
* Frontmatter parser/serializer for YAML-style --- blocks
*/
export interface NoteMeta {
title?: string;
tags: string[];
created?: string;
modified?: string;
extra: Record<string, string>;
}
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, "");
}

65
src/lib/noteCache.ts Normal file
View file

@ -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<string, CacheEntry>;
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);