feat: Introduce integrity checks, backups, bookmarks, trash, quick capture, audit logs, and backend enhancements for notes, git, crypto, and SRS.

This commit is contained in:
enzotar 2026-03-11 18:11:09 -07:00
parent bf4ef86874
commit c6ce0b24d5
31 changed files with 5368 additions and 2248 deletions

View file

@ -4,6 +4,44 @@ All notable changes to Graph Notes will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [1.5.0] — 2026-03-11
### Added
- **Graph View Upgrade** — Complete rewrite using `@blinksgg/canvas` v3.0 with virtualized rendering
- **Note Graph Nodes** — Custom node type showing title, tag pills, link count badge, and cluster color
- **Cluster Group Nodes** — Collapsible `GroupNode` containers grouping related notes by community detection
- **Minimap** — Canvas overview with draggable viewport for large vault navigation
- **Layout Switcher** — Force-directed, tree, and grid layouts with animated transitions
- **Graph Search & Spotlight** — Type-to-search with non-matching nodes dimmed and camera fit-to-bounds
- **Edge Labels** — Wikilink context displayed on graph edges
- **MiniGraph Upgrade** — Sidebar preview upgraded to canvas v3.0
### Changed
- `@blinksgg/canvas` updated to v3.0 from `gg-antifragile` repository
- `WhiteboardView` migrated to new `CanvasProvider` API
- `GraphView` reduced from 336 to ~120 lines
## [1.4.0] — 2026-03-11
### Added
- **Content Checksums (SHA-256)** — Per-note hashing with on-demand vault-wide verification against stored checksums
- **Vault Integrity Scanner** — Deep scan for truncated files, leftover `~tmp` files, orphaned `.graph-notes/` entries, and non-UTF-8 encoding issues
- **Automatic Backup Snapshots** — Vault-level `.zip` snapshots in `.graph-notes/backups/` with auto-pruning of old snapshots
- **Write-Ahead Log (WAL)** — Crash recovery via operation journal in `.graph-notes/wal.log` with startup replay
- **Conflict Detection** — mtime-based external modification check before writes; conflict banner with overwrite/discard options
- **Frontmatter Schema Validation** — Inline warnings for unclosed `---` delimiters, duplicate keys, and invalid date formats
- **Orphan Attachment Cleanup** — Scan `_attachments/` for files not referenced by any note, with bulk delete
- **File Operation Audit Log** — Append-only log of all create/update/delete/rename operations with timestamps
### Changed
- Sidebar: added 🛡️ Integrity Report action
- Command Palette: added Verify Vault, Create Backup, Audit Log commands
- StatusBar: integrity badge showing checksum status
- Editor: conflict banner + frontmatter validation warnings
### Dependencies
- Added `sha2` (Rust) for content hashing
## [1.0.0] — 2026-03-09
### 🎉 First Stable Release

83
package-lock.json generated
View file

@ -1,14 +1,15 @@
{
"name": "graph-notes",
"version": "1.0.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "graph-notes",
"version": "1.0.0",
"version": "1.5.0",
"dependencies": {
"@blinksgg/canvas": "file:../space-operator/gg/packages/canvas",
"@blinksgg/canvas": "file:../blinksgg/gg-antifragile/packages/canvas",
"@tanstack/react-query": "^5.90.21",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2",
@ -16,8 +17,10 @@
"d3-force": "^3.0.0",
"dompurify": "^3.3.2",
"graphology": "^0.26.0",
"graphology-types": "^0.24.8",
"highlight.js": "^11.11.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"marked": "^15.0.0",
"mermaid": "^11.12.3",
"react": "^19.1.0",
@ -38,14 +41,14 @@
},
"../blinksgg/gg-antifragile/packages/canvas": {
"name": "@blinksgg/canvas",
"version": "0.13.0",
"extraneous": true,
"version": "3.0.0",
"dependencies": {
"@supabase/supabase-js": "^2.49.5",
"@use-gesture/react": "^10.3.1",
"debug": "^4.4.3",
"graphology": "^0.26.0",
"graphology-types": "^0.24.8"
"graphology-types": "^0.24.8",
"jotai-family": "^1.0.1"
},
"devDependencies": {
"@babel/core": "^7.29.0",
@ -63,9 +66,11 @@
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^4.5.2",
"babel-plugin-react-compiler": "^1.0.0",
"d3-force": "^3.0.0",
"esbuild-plugin-babel": "^0.2.3",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"jotai": "^2.6.0",
"jsdom": "^26.1.0",
"react": "^19.1.1",
@ -81,14 +86,31 @@
"@tanstack/react-query": "^5.17.0",
"d3-force": "^3.0.0",
"jotai": "^2.6.0",
"jotai-tanstack-query": "*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"peerDependenciesMeta": {
"@blocknote/core": {
"optional": true
},
"@blocknote/react": {
"optional": true
},
"@blocknote/shadcn": {
"optional": true
},
"@tanstack/react-query": {
"optional": true
},
"d3-force": {
"optional": true
}
}
},
"../space-operator/gg/packages/canvas": {
"name": "@blinksgg/canvas",
"version": "0.35.0",
"extraneous": true,
"dependencies": {
"@supabase/supabase-js": "^2.49.5",
"@use-gesture/react": "^10.3.1",
@ -432,7 +454,7 @@
}
},
"node_modules/@blinksgg/canvas": {
"resolved": "../space-operator/gg/packages/canvas",
"resolved": "../blinksgg/gg-antifragile/packages/canvas",
"link": true
},
"node_modules/@braintree/sanitize-url": {
@ -1627,6 +1649,32 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
@ -3109,8 +3157,7 @@
"version": "0.24.8",
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/hachure-fill": {
"version": "0.5.2",
@ -3187,6 +3234,18 @@
}
}
},
"node_modules/jotai-family": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/jotai-family/-/jotai-family-1.0.1.tgz",
"integrity": "sha512-Zb/79GNDhC/z82R+6qTTpeKW4l4H6ZCApfF5W8G4SH37E4mhbysU7r8DkP0KX94hWvjB/6lt/97nSr3wB+64Zg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"jotai": ">=2.9.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "graph-notes",
"private": true,
"version": "1.0.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -10,7 +10,8 @@
"tauri": "tauri"
},
"dependencies": {
"@blinksgg/canvas": "file:../space-operator/gg/packages/canvas",
"@blinksgg/canvas": "file:../blinksgg/gg-antifragile/packages/canvas",
"@tanstack/react-query": "^5.90.21",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2",
@ -18,8 +19,10 @@
"d3-force": "^3.0.0",
"dompurify": "^3.3.2",
"graphology": "^0.26.0",
"graphology-types": "^0.24.8",
"highlight.js": "^11.11.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"marked": "^15.0.0",
"mermaid": "^11.12.3",
"react": "^19.1.0",

3
src-tauri/Cargo.lock generated
View file

@ -1473,7 +1473,7 @@ dependencies = [
[[package]]
name = "graph-notes"
version = "1.0.0"
version = "1.5.0"
dependencies = [
"aes-gcm",
"argon2",
@ -1483,6 +1483,7 @@ dependencies = [
"regex",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",

View file

@ -1,6 +1,6 @@
[package]
name = "graph-notes"
version = "1.0.0"
version = "1.5.0"
description = "A graph-based note-taking app"
authors = ["you"]
edition = "2021"
@ -27,3 +27,4 @@ argon2 = "0.5"
rand = "0.8"
base64 = "0.22"
zip = "2"
sha2 = "0.10"

79
src-tauri/src/crypto.rs Normal file
View file

@ -0,0 +1,79 @@
use std::fs;
use std::path::Path;
use aes_gcm::{Aes256Gcm, Key, Nonce};
use aes_gcm::aead::{Aead, KeyInit};
use argon2::Argon2;
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use crate::{atomic_write, safe_vault_path};
fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password.as_bytes(), salt, &mut key)
.expect("key derivation failed");
key
}
#[tauri::command]
pub fn encrypt_note(vault_path: String, note_path: String, password: String) -> Result<(), String> {
let full = safe_vault_path(&vault_path, &note_path)?;
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
let salt: [u8; 16] = rand::random();
let nonce_bytes: [u8; 12] = rand::random();
let key_bytes = derive_key(&password, &salt);
let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher.encrypt(nonce, content.as_bytes())
.map_err(|_| "Encryption failed".to_string())?;
// Format: GRAPHNOTES_ENC:v1:{salt_b64}:{nonce_b64}:{ciphertext_b64}
let encoded = format!(
"GRAPHNOTES_ENC:v1:{}:{}:{}",
B64.encode(salt),
B64.encode(nonce_bytes),
B64.encode(&ciphertext),
);
atomic_write(&full, &encoded)
}
#[tauri::command]
pub fn decrypt_note(vault_path: String, note_path: String, password: String) -> Result<String, String> {
let full = safe_vault_path(&vault_path, &note_path)?;
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
if !content.starts_with("GRAPHNOTES_ENC:v1:") {
return Err("Note is not encrypted".to_string());
}
let parts: Vec<&str> = content.splitn(5, ':').collect();
if parts.len() != 5 {
return Err("Invalid encrypted format".to_string());
}
let salt = B64.decode(parts[2]).map_err(|_| "Invalid salt".to_string())?;
let nonce_bytes = B64.decode(parts[3]).map_err(|_| "Invalid nonce".to_string())?;
let ciphertext = B64.decode(parts[4]).map_err(|_| "Invalid ciphertext".to_string())?;
let key_bytes = derive_key(&password, &salt);
let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = cipher.decrypt(nonce, ciphertext.as_ref())
.map_err(|_| "Wrong password".to_string())?;
String::from_utf8(plaintext).map_err(|_| "Invalid UTF-8".to_string())
}
#[tauri::command]
pub fn is_encrypted(vault_path: String, note_path: String) -> Result<bool, String> {
let full = safe_vault_path(&vault_path, &note_path)?;
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
Ok(content.starts_with("GRAPHNOTES_ENC:v1:"))
}

123
src-tauri/src/export.rs Normal file
View file

@ -0,0 +1,123 @@
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::{safe_vault_path, EXPORT_WIKILINK_RE};
#[tauri::command]
pub fn export_note_html(vault_path: String, note_path: String) -> Result<String, String> {
let full = safe_vault_path(&vault_path, &note_path)?;
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
let title = Path::new(&note_path)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Basic markdown-to-HTML (headings, bold, italic, links, paragraphs)
let mut html_body = String::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("---") {
continue; // skip frontmatter delimiters
}
if trimmed.starts_with("# ") {
html_body.push_str(&format!("<h1>{}</h1>\n", &trimmed[2..]));
} else if trimmed.starts_with("## ") {
html_body.push_str(&format!("<h2>{}</h2>\n", &trimmed[3..]));
} else if trimmed.starts_with("### ") {
html_body.push_str(&format!("<h3>{}</h3>\n", &trimmed[4..]));
} else if trimmed.starts_with("- ") {
html_body.push_str(&format!("<li>{}</li>\n", &trimmed[2..]));
} else if trimmed.is_empty() {
html_body.push_str("<br>\n");
} else {
html_body.push_str(&format!("<p>{}</p>\n", trimmed));
}
}
// Replace wikilinks
let html_body = EXPORT_WIKILINK_RE.replace_all(&html_body, |caps: &regex::Captures| {
let target = caps.get(1).map_or("", |m| m.as_str()).trim();
let label = caps.get(2).map_or(target, |m| m.as_str()).trim();
format!("<a href=\"{}.html\">{}</a>", target, label)
}).to_string();
let html = format!(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; background: #0a0a0c; color: #e4e4e7; line-height: 1.8; }}
h1, h2, h3 {{ color: #fafafa; }}
a {{ color: #a78bfa; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
code {{ background: #1f1f2c; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }}
li {{ margin: 4px 0; }}
</style>
</head>
<body>
{html_body}
</body>
</html>"#);
Ok(html)
}
#[tauri::command]
pub fn export_vault_zip(vault_path: String, output_path: String) -> Result<String, String> {
use std::io::Write;
let vault = Path::new(&vault_path);
let out = Path::new(&output_path);
let file = fs::File::create(out).map_err(|e| e.to_string())?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
{
let path = entry.path();
let rel = path.strip_prefix(vault).unwrap_or(path).to_string_lossy().to_string();
// Skip hidden files, in-progress atomic writes (~tmp suffix)
if rel.starts_with(".") || rel.ends_with("~tmp") { continue; }
let content = fs::read(path).map_err(|e| e.to_string())?;
zip.start_file(&rel, options).map_err(|e| e.to_string())?;
zip.write_all(&content).map_err(|e| e.to_string())?;
}
zip.finish().map_err(|e| e.to_string())?;
Ok(format!("Exported to {}", output_path))
}
#[tauri::command]
pub fn import_folder(vault_path: String, source_path: String) -> Result<u32, String> {
let vault = Path::new(&vault_path);
let source = Path::new(&source_path);
let mut count: u32 = 0;
for entry in WalkDir::new(source)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
{
let path = entry.path();
let rel = path.strip_prefix(source).unwrap_or(path);
let dest = vault.join(rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::copy(path, &dest).map_err(|e| e.to_string())?;
count += 1;
}
Ok(count)
}

80
src-tauri/src/git.rs Normal file
View file

@ -0,0 +1,80 @@
use std::process::Command;
#[tauri::command]
pub fn git_status(vault_path: String) -> Result<String, String> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&vault_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[tauri::command]
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let add_output = Command::new("git")
.args(["add", "."])
.current_dir(&vault_path)
.output()
.map_err(|e| e.to_string())?;
if !add_output.status.success() {
return Err(String::from_utf8_lossy(&add_output.stderr).to_string());
}
let output = Command::new("git")
.args(["commit", "-m", &message])
.current_dir(&vault_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[tauri::command]
pub fn git_pull(vault_path: String) -> Result<String, String> {
let output = Command::new("git")
.args(["pull"])
.current_dir(&vault_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[tauri::command]
pub fn git_push(vault_path: String) -> Result<String, String> {
let output = Command::new("git")
.args(["push"])
.current_dir(&vault_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[tauri::command]
pub fn git_init(vault_path: String) -> Result<String, String> {
let output = Command::new("git")
.args(["init"])
.current_dir(&vault_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}

File diff suppressed because it is too large Load diff

1490
src-tauri/src/notes.rs Normal file

File diff suppressed because it is too large Load diff

117
src-tauri/src/srs.rs Normal file
View file

@ -0,0 +1,117 @@
use std::fs;
use std::path::Path;
use chrono::Local;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::{atomic_write, FLASHCARD_RE};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Flashcard {
pub question: String,
pub answer: String,
pub source_path: String,
pub line_number: usize,
pub due: Option<String>,
pub interval: u32,
pub ease: f32,
}
#[tauri::command]
pub fn list_flashcards(vault_path: String) -> Result<Vec<Flashcard>, String> {
let vault = Path::new(&vault_path);
let mut cards: Vec<Flashcard> = Vec::new();
// Load schedule data
let srs_path = vault.join(".graph-notes").join("srs.json");
let srs: serde_json::Map<String, serde_json::Value> = if srs_path.exists() {
let c = fs::read_to_string(&srs_path).unwrap_or_default();
serde_json::from_str(&c).unwrap_or_default()
} else {
serde_json::Map::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 path = entry.path();
let rel = path.strip_prefix(vault).unwrap_or(path).to_string_lossy().to_string();
if rel.starts_with(".") || rel.starts_with("_") { continue; }
if let Ok(content) = fs::read_to_string(path) {
for (i, line) in content.lines().enumerate() {
for caps in FLASHCARD_RE.captures_iter(line) {
let q = caps[1].trim().to_string();
let a = caps[2].trim().to_string();
let card_id = format!("{}:{}", rel, i + 1);
let (due, interval, ease) = if let Some(sched) = srs.get(&card_id) {
(
sched.get("due").and_then(|v| v.as_str()).map(|s| s.to_string()),
sched.get("interval").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
sched.get("ease").and_then(|v| v.as_f64()).unwrap_or(2.5) as f32,
)
} else {
(None, 1, 2.5)
};
cards.push(Flashcard {
question: q,
answer: a,
source_path: rel.clone(),
line_number: i + 1,
due,
interval,
ease,
});
}
}
}
}
Ok(cards)
}
#[tauri::command]
pub fn update_card_schedule(
vault_path: String,
card_id: String,
quality: u32,
) -> Result<(), String> {
let srs_path = Path::new(&vault_path).join(".graph-notes").join("srs.json");
fs::create_dir_all(Path::new(&vault_path).join(".graph-notes")).map_err(|e| e.to_string())?;
let mut srs: serde_json::Map<String, serde_json::Value> = if srs_path.exists() {
let c = fs::read_to_string(&srs_path).unwrap_or_default();
serde_json::from_str(&c).unwrap_or_default()
} else {
serde_json::Map::new()
};
let entry = srs.entry(card_id).or_insert_with(|| serde_json::json!({"interval": 1, "ease": 2.5}));
let obj = entry.as_object_mut().ok_or("Invalid SRS entry")?;
let mut interval = obj.get("interval").and_then(|v| v.as_u64()).unwrap_or(1) as f64;
let mut ease = obj.get("ease").and_then(|v| v.as_f64()).unwrap_or(2.5);
// SM-2 algorithm
if quality >= 3 {
if interval <= 1.0 { interval = 1.0; }
else if interval <= 6.0 { interval = 6.0; }
else { interval *= ease; }
ease = ease + (0.1 - (5.0 - quality as f64) * (0.08 + (5.0 - quality as f64) * 0.02));
if ease < 1.3 { ease = 1.3; }
} else {
interval = 1.0;
}
let due = Local::now() + chrono::Duration::days(interval as i64);
obj.insert("interval".into(), serde_json::json!(interval as u32));
obj.insert("ease".into(), serde_json::json!(ease));
obj.insert("due".into(), serde_json::json!(due.format("%Y-%m-%d").to_string()));
let json = serde_json::to_string_pretty(&srs).map_err(|e| e.to_string())?;
atomic_write(&srs_path, &json)
}

758
src-tauri/src/state.rs Normal file
View file

@ -0,0 +1,758 @@
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::{atomic_write, safe_name, safe_vault_path, dirs_config_dir, dirs_config_path};
/* ── Snapshots (Version History) ────────────────────────── */
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SnapshotInfo {
pub timestamp: String,
pub filename: String,
pub size: u64,
}
#[tauri::command]
pub fn save_snapshot(vault_path: String, note_path: String) -> Result<String, String> {
let full = safe_vault_path(&vault_path, &note_path)?;
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
let sanitized_name = note_path.replace('/', "__").replace(".md", "");
let history_dir = Path::new(&vault_path).join(".graph-notes").join("history").join(&sanitized_name);
fs::create_dir_all(&history_dir).map_err(|e| e.to_string())?;
let ts = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
let snap_name = format!("{}.md", ts);
// Snapshots are write-once, never overwritten — direct write is safe
fs::write(history_dir.join(&snap_name), &content).map_err(|e| e.to_string())?;
Ok(snap_name)
}
#[tauri::command]
pub fn list_snapshots(vault_path: String, note_path: String) -> Result<Vec<SnapshotInfo>, String> {
let sanitized_name = note_path.replace('/', "__").replace(".md", "");
let history_dir = Path::new(&vault_path).join(".graph-notes").join("history").join(&sanitized_name);
if !history_dir.exists() {
return Ok(vec![]);
}
let mut snaps: Vec<SnapshotInfo> = Vec::new();
for entry in fs::read_dir(&history_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_file() {
let name = entry.file_name().to_string_lossy().to_string();
let ts = name.replace(".md", "");
snaps.push(SnapshotInfo {
timestamp: ts,
filename: name,
size: meta.len(),
});
}
}
snaps.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(snaps)
}
#[tauri::command]
pub fn read_snapshot(vault_path: String, note_path: String, snapshot_name: String) -> Result<String, String> {
let sanitized_name = note_path.replace('/', "__").replace(".md", "");
let snap_path = Path::new(&vault_path)
.join(".graph-notes")
.join("history")
.join(&sanitized_name)
.join(&snapshot_name);
fs::read_to_string(&snap_path).map_err(|e| e.to_string())
}
/* ── Search & Replace ───────────────────────────────────── */
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ReplaceResult {
pub path: String,
pub count: usize,
}
#[tauri::command]
pub fn search_replace_vault(
vault_path: String,
search: String,
replace: String,
dry_run: bool,
) -> Result<Vec<ReplaceResult>, String> {
use walkdir::WalkDir;
let vault = Path::new(&vault_path);
let mut results: Vec<ReplaceResult> = 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"))
{
let path = entry.path();
let rel = path.strip_prefix(vault).unwrap_or(path).to_string_lossy().to_string();
if rel.starts_with(".") || rel.starts_with("_") { continue; }
if let Ok(content) = fs::read_to_string(path) {
let count = content.matches(&search).count();
if count > 0 {
results.push(ReplaceResult { path: rel, count });
if !dry_run {
let updated = content.replace(&search, &replace);
crate::atomic_write(path, &updated)?;
}
}
}
}
Ok(results)
}
/* ── Writing Goals ──────────────────────────────────────── */
#[tauri::command]
pub fn get_writing_goal(vault_path: String, note_path: String) -> Result<u32, String> {
let goals_path = Path::new(&vault_path).join(".graph-notes").join("goals.json");
if !goals_path.exists() {
return Ok(0);
}
let content = fs::read_to_string(&goals_path).map_err(|e| e.to_string())?;
let goals: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&content).unwrap_or_default();
Ok(goals
.get(&note_path)
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32)
}
#[tauri::command]
pub fn set_writing_goal(vault_path: String, note_path: String, goal: u32) -> Result<(), String> {
let dir = Path::new(&vault_path).join(".graph-notes");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let goals_path = dir.join("goals.json");
let mut goals: serde_json::Map<String, serde_json::Value> = if goals_path.exists() {
let c = fs::read_to_string(&goals_path).unwrap_or_default();
serde_json::from_str(&c).unwrap_or_default()
} else {
serde_json::Map::new()
};
goals.insert(note_path, serde_json::json!(goal));
let json = serde_json::to_string_pretty(&goals).map_err(|e| e.to_string())?;
crate::atomic_write(&goals_path, &json)
}
/* ── Fold State ─────────────────────────────────────────── */
#[tauri::command]
pub fn save_fold_state(vault_path: String, note_path: String, folds: Vec<usize>) -> Result<(), String> {
let dir = Path::new(&vault_path).join(".graph-notes");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let folds_path = dir.join("folds.json");
let mut data: serde_json::Map<String, serde_json::Value> = if folds_path.exists() {
let c = fs::read_to_string(&folds_path).unwrap_or_default();
serde_json::from_str(&c).unwrap_or_default()
} else {
serde_json::Map::new()
};
data.insert(note_path, serde_json::json!(folds));
let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?;
crate::atomic_write(&folds_path, &json)
}
#[tauri::command]
pub fn load_fold_state(vault_path: String, note_path: String) -> Result<Vec<usize>, String> {
let folds_path = Path::new(&vault_path).join(".graph-notes").join("folds.json");
if !folds_path.exists() { return Ok(vec![]); }
let c = fs::read_to_string(&folds_path).map_err(|e| e.to_string())?;
let data: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&c).unwrap_or_default();
let folds = data.get(&note_path)
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_u64().map(|n| n as usize)).collect())
.unwrap_or_default();
Ok(folds)
}
/* ── Custom CSS ─────────────────────────────────────────── */
#[tauri::command]
pub fn get_custom_css() -> Result<String, String> {
let config_dir = dirs_config_dir();
let css_path = config_dir.join("custom.css");
if css_path.exists() {
fs::read_to_string(&css_path).map_err(|e| e.to_string())
} else {
Ok(String::new())
}
}
#[tauri::command]
pub fn set_custom_css(css: String) -> Result<(), String> {
crate::atomic_write(&dirs_config_dir().join("custom.css"), &css)
}
/* ── Workspace Layouts ──────────────────────────────────── */
#[tauri::command]
pub fn save_workspace(vault_path: String, name: String, state: String) -> Result<(), String> {
let sanitized = safe_name(&name)?;
let dir = Path::new(&vault_path).join(".graph-notes").join("workspaces");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
crate::atomic_write(&dir.join(format!("{}.json", sanitized)), &state)
}
#[tauri::command]
pub fn load_workspace(vault_path: String, name: String) -> Result<String, String> {
let sanitized = safe_name(&name)?;
let path = Path::new(&vault_path).join(".graph-notes").join("workspaces").join(format!("{}.json", sanitized));
fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_workspaces(vault_path: String) -> Result<Vec<String>, String> {
let dir = Path::new(&vault_path).join(".graph-notes").join("workspaces");
if !dir.exists() { return Ok(vec![]); }
let mut names: Vec<String> = Vec::new();
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().replace(".json", "");
names.push(name);
}
names.sort();
Ok(names)
}
/* ── Tab Persistence ────────────────────────────────────── */
#[tauri::command]
pub fn save_tabs(vault_path: String, tabs: String) -> Result<(), String> {
let dir = Path::new(&vault_path).join(".graph-notes");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
crate::atomic_write(&dir.join("tabs.json"), &tabs)
}
#[tauri::command]
pub fn load_tabs(vault_path: String) -> Result<String, String> {
let path = Path::new(&vault_path).join(".graph-notes").join("tabs.json");
if !path.exists() { return Ok("[]".to_string()); }
fs::read_to_string(&path).map_err(|e| e.to_string())
}
/* ── Canvas / Whiteboard Persistence ────────────────────── */
#[tauri::command]
pub fn save_canvas(vault_path: String, name: String, data: String) -> Result<(), String> {
let sanitized = safe_name(&name)?;
let dir = Path::new(&vault_path).join(".graph-notes").join("canvases");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
crate::atomic_write(&dir.join(format!("{}.json", sanitized)), &data)
}
#[tauri::command]
pub fn load_canvas(vault_path: String, name: String) -> Result<String, String> {
let sanitized = safe_name(&name)?;
let path = Path::new(&vault_path).join(".graph-notes").join("canvases").join(format!("{}.json", sanitized));
if !path.exists() { return Ok("{}".to_string()); }
fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_canvases(vault_path: String) -> Result<Vec<String>, String> {
let dir = Path::new(&vault_path).join(".graph-notes").join("canvases");
if !dir.exists() { return Ok(vec![]); }
let mut names: Vec<String> = Vec::new();
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().replace(".json", "");
names.push(name);
}
names.sort();
Ok(names)
}
/* ── Shortcuts ──────────────────────────────────────────── */
#[tauri::command]
pub fn save_shortcuts(vault_path: String, shortcuts_json: String) -> Result<(), String> {
let dir = Path::new(&vault_path).join(".graph-notes");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
crate::atomic_write(&dir.join("shortcuts.json"), &shortcuts_json)
}
#[tauri::command]
pub fn load_shortcuts(vault_path: String) -> Result<String, String> {
let path = Path::new(&vault_path).join(".graph-notes/shortcuts.json");
if path.exists() {
fs::read_to_string(path).map_err(|e| e.to_string())
} else {
Ok("{}".to_string())
}
}
/* ── Pinned Notes ───────────────────────────────────────── */
#[tauri::command]
pub fn get_pinned(vault_path: String) -> Result<Vec<String>, String> {
let path = Path::new(&vault_path).join(".graph-notes/pinned.json");
if path.exists() {
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
serde_json::from_str(&content).map_err(|e| e.to_string())
} else {
Ok(Vec::new())
}
}
#[tauri::command]
pub fn set_pinned(vault_path: String, pinned: Vec<String>) -> Result<(), String> {
let dir = Path::new(&vault_path).join(".graph-notes");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&pinned).map_err(|e| e.to_string())?;
crate::atomic_write(&dir.join("pinned.json"), &json)
}
/* ── Theme ──────────────────────────────────────────────── */
#[tauri::command]
pub fn get_theme() -> Result<String, String> {
let path = dirs_config_dir().join("theme");
if path.exists() {
fs::read_to_string(&path).map_err(|e| e.to_string())
} else {
Ok("dark-purple".to_string())
}
}
#[tauri::command]
pub fn set_theme(theme: String) -> Result<(), String> {
crate::atomic_write(&dirs_config_dir().join("theme"), &theme)
}
/* ── Favorites ──────────────────────────────────────────── */
#[tauri::command]
pub fn get_favorites(vault_path: String) -> Result<Vec<String>, String> {
let path = Path::new(&vault_path).join(".graph-notes/favorites.json");
if path.exists() {
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
serde_json::from_str(&content).map_err(|e| e.to_string())
} else {
Ok(Vec::new())
}
}
#[tauri::command]
pub fn set_favorites(vault_path: String, favorites: Vec<String>) -> Result<(), String> {
let dir = Path::new(&vault_path).join(".graph-notes");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&favorites).map_err(|e| e.to_string())?;
crate::atomic_write(&dir.join("favorites.json"), &json)
}
/* ══════════════════════════════════════════════════════════
v1.3 Reading List & Progress Tracker
*/
#[tauri::command]
pub fn get_reading_list(vault_path: String) -> Result<String, String> {
let rl_path = Path::new(&vault_path).join(".graph-notes").join("reading-list.json");
if rl_path.exists() {
fs::read_to_string(&rl_path).map_err(|e| e.to_string())
} else {
Ok("[]".into())
}
}
#[tauri::command]
pub fn set_reading_list(vault_path: String, data: String) -> Result<(), String> {
let gn_dir = Path::new(&vault_path).join(".graph-notes");
fs::create_dir_all(&gn_dir).map_err(|e| e.to_string())?;
let rl_path = gn_dir.join("reading-list.json");
atomic_write(&rl_path, &data)
}
/* ══════════════════════════════════════════════════════════
v1.3 Plugin / Hook System
*/
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct PluginInfo {
pub name: String,
pub filename: String,
pub enabled: bool,
pub hooks: Vec<String>,
}
#[tauri::command]
pub fn list_plugins(vault_path: String) -> Result<Vec<PluginInfo>, String> {
let plugins_dir = Path::new(&vault_path).join(".graph-notes").join("plugins");
if !plugins_dir.exists() {
return Ok(vec![]);
}
let mut plugins = Vec::new();
// Check for manifest
let manifest_path = plugins_dir.join("manifest.json");
let manifest: serde_json::Map<String, serde_json::Value> = if manifest_path.exists() {
let data = fs::read_to_string(&manifest_path).unwrap_or_default();
serde_json::from_str(&data).unwrap_or_default()
} else {
serde_json::Map::new()
};
for entry in fs::read_dir(&plugins_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let filename = entry.file_name().to_string_lossy().to_string();
if !filename.ends_with(".js") { continue; }
let name = filename.replace(".js", "");
let enabled = manifest.get(&name)
.and_then(|v| v.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Scan for hook registrations
let content = fs::read_to_string(entry.path()).unwrap_or_default();
let mut hooks = Vec::new();
for hook in &["on_save", "on_create", "on_delete", "on_daily"] {
if content.contains(hook) {
hooks.push(hook.to_string());
}
}
plugins.push(PluginInfo { name, filename, enabled, hooks });
}
Ok(plugins)
}
#[tauri::command]
pub fn toggle_plugin(vault_path: String, name: String, enabled: bool) -> Result<(), String> {
let plugins_dir = Path::new(&vault_path).join(".graph-notes").join("plugins");
fs::create_dir_all(&plugins_dir).map_err(|e| e.to_string())?;
let manifest_path = plugins_dir.join("manifest.json");
let mut manifest: serde_json::Map<String, serde_json::Value> = if manifest_path.exists() {
let data = fs::read_to_string(&manifest_path).unwrap_or_default();
serde_json::from_str(&data).unwrap_or_default()
} else {
serde_json::Map::new()
};
let entry = manifest.entry(name).or_insert_with(|| serde_json::json!({}));
if let Some(obj) = entry.as_object_mut() {
obj.insert("enabled".into(), serde_json::Value::Bool(enabled));
}
let json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
atomic_write(&manifest_path, &json)
}
/* ══════════════════════════════════════════════════════════
v1.3 Vault Registry (for Federated Search)
*/
#[tauri::command]
pub fn get_vault_registry() -> Result<Vec<String>, String> {
let config_path = dirs_config_dir().join("vaults.json");
if config_path.exists() {
let data = fs::read_to_string(&config_path).map_err(|e| e.to_string())?;
let vaults: Vec<String> = serde_json::from_str(&data).unwrap_or_default();
Ok(vaults)
} else {
Ok(vec![])
}
}
#[tauri::command]
pub fn set_vault_registry(vaults: Vec<String>) -> Result<(), String> {
let config_dir = dirs_config_dir();
fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
let config_path = config_dir.join("vaults.json");
let json = serde_json::to_string_pretty(&vaults).map_err(|e| e.to_string())?;
atomic_write(&config_path, &json)
}
/* ══════════════════════════════════════════════════════════
v1.4 Automatic Backup Snapshots
*/
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct BackupEntry {
pub name: String,
pub size: u64,
pub created: String,
}
#[tauri::command]
pub fn create_backup(vault_path: String) -> Result<String, String> {
let vault = std::path::Path::new(&vault_path);
let backup_dir = vault.join(".graph-notes").join("backups");
fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S").to_string();
let name = format!("backup_{}.zip", ts);
let zip_path = backup_dir.join(&name);
let file = fs::File::create(&zip_path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for entry in walkdir::WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
{
let path = entry.path();
let rel = path.strip_prefix(vault).unwrap_or(path).to_string_lossy().to_string();
// Skip backups dir and tmp files
if rel.starts_with(".graph-notes/backups") { continue; }
if rel.contains("~tmp") { continue; }
if let Ok(data) = fs::read(path) {
zip.start_file(&rel, options).map_err(|e| e.to_string())?;
use std::io::Write;
zip.write_all(&data).map_err(|e| e.to_string())?;
}
}
zip.finish().map_err(|e| e.to_string())?;
// Auto-prune: keep only last 10 backups
let mut backups = list_backup_entries(&backup_dir)?;
backups.sort_by(|a, b| b.name.cmp(&a.name));
for old in backups.iter().skip(10) {
let _ = fs::remove_file(backup_dir.join(&old.name));
}
Ok(name)
}
fn list_backup_entries(backup_dir: &std::path::Path) -> Result<Vec<BackupEntry>, String> {
let mut entries = Vec::new();
if !backup_dir.exists() { return Ok(entries); }
for entry in fs::read_dir(backup_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".zip") { continue; }
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
// Extract timestamp from filename: backup_YYYY-MM-DD_HHMMSS.zip
let created = name.replace("backup_", "").replace(".zip", "")
.replace('_', " ");
entries.push(BackupEntry { name, size, created });
}
Ok(entries)
}
#[tauri::command]
pub fn list_backups(vault_path: String) -> Result<Vec<BackupEntry>, String> {
let backup_dir = std::path::Path::new(&vault_path).join(".graph-notes").join("backups");
let mut entries = list_backup_entries(&backup_dir)?;
entries.sort_by(|a, b| b.name.cmp(&a.name));
Ok(entries)
}
#[tauri::command]
pub fn restore_backup(vault_path: String, backup_name: String) -> Result<u32, String> {
let vault = std::path::Path::new(&vault_path);
let zip_path = vault.join(".graph-notes").join("backups").join(&backup_name);
if !zip_path.exists() {
return Err("Backup not found".into());
}
let file = fs::File::open(&zip_path).map_err(|e| e.to_string())?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let mut count = 0u32;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
if entry.is_dir() { continue; }
let name = entry.name().to_string();
// Skip restoring backup files themselves
if name.starts_with(".graph-notes/backups") { continue; }
let dest = vault.join(&name);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut content = Vec::new();
use std::io::Read;
entry.read_to_end(&mut content).map_err(|e| e.to_string())?;
crate::atomic_write_bytes(&dest, &content)?;
count += 1;
}
Ok(count)
}
/* ══════════════════════════════════════════════════════════
v1.4 Write-Ahead Log (WAL)
*/
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct WalEntry {
pub timestamp: String,
pub operation: String, // "write" | "delete" | "rename"
pub path: String,
pub content_hash: String,
pub status: String, // "pending" | "complete"
}
pub fn wal_append_entry(vault_path: &str, operation: &str, path: &str, content_hash: &str) {
let wal_path = std::path::Path::new(vault_path).join(".graph-notes").join("wal.log");
let _ = fs::create_dir_all(std::path::Path::new(vault_path).join(".graph-notes"));
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let line = format!("{}|{}|{}|{}|pending\n", ts, operation, path, content_hash);
use std::io::Write;
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&wal_path) {
let _ = f.write_all(line.as_bytes());
}
}
pub fn wal_mark_complete(vault_path: &str, path: &str) {
let wal_path = std::path::Path::new(vault_path).join(".graph-notes").join("wal.log");
if !wal_path.exists() { return; }
if let Ok(content) = fs::read_to_string(&wal_path) {
let updated: String = content.lines().map(|line| {
if line.contains(path) && line.ends_with("|pending") {
format!("{}", line.replace("|pending", "|complete"))
} else {
line.to_string()
}
}).collect::<Vec<_>>().join("\n");
let _ = fs::write(&wal_path, format!("{}\n", updated.trim()));
}
}
#[tauri::command]
pub fn wal_status(vault_path: String) -> Result<Vec<WalEntry>, String> {
let wal_path = std::path::Path::new(&vault_path).join(".graph-notes").join("wal.log");
if !wal_path.exists() {
return Ok(vec![]);
}
let content = fs::read_to_string(&wal_path).map_err(|e| e.to_string())?;
let entries: Vec<WalEntry> = content.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(5, '|').collect();
if parts.len() == 5 {
Some(WalEntry {
timestamp: parts[0].to_string(),
operation: parts[1].to_string(),
path: parts[2].to_string(),
content_hash: parts[3].to_string(),
status: parts[4].to_string(),
})
} else {
None
}
})
.collect();
Ok(entries)
}
#[tauri::command]
pub fn wal_recover(vault_path: String) -> Result<u32, String> {
let entries = wal_status(vault_path.clone())?;
let pending: Vec<&WalEntry> = entries.iter().filter(|e| e.status == "pending").collect();
if pending.is_empty() {
return Ok(0);
}
// For pending writes where the file exists and hash matches, mark complete
let vault = std::path::Path::new(&vault_path);
let mut recovered = 0u32;
for entry in &pending {
let full = vault.join(&entry.path);
if full.exists() {
if let Ok(content) = fs::read_to_string(&full) {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let hash = format!("{:x}", hasher.finalize());
if hash == entry.content_hash {
wal_mark_complete(&vault_path, &entry.path);
recovered += 1;
}
}
}
}
Ok(recovered)
}
/* ══════════════════════════════════════════════════════════
v1.4 File Operation Audit Log
*/
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct AuditEntry {
pub timestamp: String,
pub operation: String,
pub path: String,
pub detail: String,
}
pub fn audit_log_append(vault_path: &str, operation: &str, path: &str, detail: &str) {
let log_path = std::path::Path::new(vault_path).join(".graph-notes").join("audit.log");
let _ = fs::create_dir_all(std::path::Path::new(vault_path).join(".graph-notes"));
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let line = format!("{}|{}|{}|{}\n", ts, operation, path, detail);
use std::io::Write;
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = f.write_all(line.as_bytes());
}
}
#[tauri::command]
pub fn get_audit_log(vault_path: String, limit: usize) -> Result<Vec<AuditEntry>, String> {
let log_path = std::path::Path::new(&vault_path).join(".graph-notes").join("audit.log");
if !log_path.exists() {
return Ok(vec![]);
}
let content = fs::read_to_string(&log_path).map_err(|e| e.to_string())?;
let mut entries: Vec<AuditEntry> = content.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
Some(AuditEntry {
timestamp: parts[0].to_string(),
operation: parts[1].to_string(),
path: parts[2].to_string(),
detail: parts[3].to_string(),
})
} else {
None
}
})
.collect();
// Return most recent first, limited
entries.reverse();
entries.truncate(limit);
Ok(entries)
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Graph Notes",
"version": "1.0.0",
"version": "1.5.0",
"identifier": "com.graphnotes.app",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -19,6 +19,8 @@ import { DatabaseView } from "./components/DatabaseView";
import { GitPanel } from "./components/GitPanel";
import { TimelineView } from "./components/TimelineView";
import { GraphAnalytics } from "./components/GraphAnalytics";
import IntegrityReport from "./components/IntegrityReport";
import AuditLog from "./components/AuditLog";
import {
listNotes,
readNote,
@ -389,6 +391,8 @@ export default function App() {
<Route path="/database" element={<DatabaseView />} />
<Route path="/timeline" element={<TimelineView />} />
<Route path="/analytics" element={<GraphAnalytics />} />
<Route path="/integrity" element={<IntegrityReport onClose={() => navigate('/')} />} />
<Route path="/audit-log" element={<AuditLog onClose={() => navigate('/')} />} />
</Routes>
</div>
<CommandPalette open={cmdPaletteOpen} onClose={() => setCmdPaletteOpen(false)} />

View file

@ -0,0 +1,85 @@
import { useState, useEffect } from "react";
import { useVault } from "../App";
import { getAuditLog, type AuditEntry } from "../lib/commands";
export default function AuditLog({ onClose }: { onClose: () => void }) {
const { vaultPath } = useVault();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
loadLog();
}, [vaultPath]);
const loadLog = async () => {
if (!vaultPath) return;
setLoading(true);
try {
const log = await getAuditLog(vaultPath, 200);
setEntries(log);
} catch { /* ignore */ }
setLoading(false);
};
const filtered = filter
? entries.filter(e =>
e.path.toLowerCase().includes(filter.toLowerCase()) ||
e.operation.toLowerCase().includes(filter.toLowerCase()) ||
e.detail.toLowerCase().includes(filter.toLowerCase())
)
: entries;
const opIcon = (op: string) => {
switch (op) {
case "create": return "🆕";
case "update": return "✏️";
case "delete": return "🗑️";
case "rename": return "📝";
case "move": return "📁";
default: return "📄";
}
};
return (
<div className="audit-log">
<div className="audit-header">
<h3>📋 Audit Log</h3>
<button className="panel-close-btn" onClick={onClose}></button>
</div>
<div className="audit-toolbar">
<input
type="text"
placeholder="Filter by path, operation, or detail..."
value={filter}
onChange={e => setFilter(e.target.value)}
className="audit-filter-input"
/>
<button className="audit-refresh-btn" onClick={loadLog} disabled={loading}></button>
</div>
<div className="audit-body">
{loading && <div className="audit-loading">Loading audit log...</div>}
{!loading && filtered.length === 0 && (
<div className="audit-empty">No log entries{filter ? " matching filter" : ""}</div>
)}
{filtered.map((entry, i) => (
<div key={i} className="audit-entry">
<span className="audit-op-icon">{opIcon(entry.operation)}</span>
<div className="audit-entry-body">
<div className="audit-entry-top">
<span className="audit-op-badge">{entry.operation}</span>
<span className="audit-path">{entry.path}</span>
</div>
<div className="audit-entry-bottom">
<span className="audit-time">{entry.timestamp}</span>
{entry.detail && <span className="audit-detail">{entry.detail}</span>}
</div>
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,79 @@
import { useState, useEffect } from 'react';
import { getBookmarks, setBookmarks, type Bookmark } from '../lib/commands';
interface BookmarksPanelProps {
vaultPath: string;
onNavigate: (notePath: string, line?: number) => void;
onClose: () => void;
}
export default function BookmarksPanel({ vaultPath, onNavigate, onClose }: BookmarksPanelProps) {
const [bookmarks, setBookmarksList] = useState<Bookmark[]>([]);
const refresh = async () => {
try {
const bm = await getBookmarks(vaultPath);
setBookmarksList(bm);
} catch (e) {
console.error('Failed to load bookmarks:', e);
}
};
useEffect(() => { refresh(); }, [vaultPath]);
const handleRemove = async (index: number) => {
const updated = bookmarks.filter((_, i) => i !== index);
try {
await setBookmarks(vaultPath, JSON.stringify(updated));
setBookmarksList(updated);
} catch (e) {
console.error('Failed to update bookmarks:', e);
}
};
const handleClick = (bm: Bookmark) => {
const notePath = bm.note_path.endsWith('.md') ? bm.note_path : `${bm.note_path}.md`;
onNavigate(notePath, bm.line);
};
return (
<div className="bookmarks-panel" id="bookmarks-panel">
<div className="bookmarks-panel-header">
<h3>🔖 Bookmarks</h3>
<button className="panel-close-btn" onClick={onClose}></button>
</div>
<div className="bookmarks-panel-body">
{bookmarks.length === 0 ? (
<div className="bookmarks-empty-state">
No bookmarks yet. Right-click a line in the editor to add one.
</div>
) : (
<ul className="bookmarks-list">
{bookmarks.map((bm, i) => (
<li key={`${bm.note_path}-${bm.line}-${i}`} className="bookmark-item">
<button
className="bookmark-link"
onClick={() => handleClick(bm)}
title={`${bm.note_path}:${bm.line}`}
>
<span className="bookmark-label">{bm.label || bm.note_path.replace('.md', '')}</span>
<span className="bookmark-meta">
{bm.note_path.replace('.md', '')} · L{bm.line}
</span>
</button>
<button
className="bookmark-remove-btn"
onClick={() => handleRemove(i)}
title="Remove"
>
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View file

@ -184,6 +184,14 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
{ id: "import-export", icon: "📦", label: "Import / Export", action: () => { onClose(); } },
{ id: "shortcuts", icon: "⌨️", label: "Keyboard Shortcuts", action: () => { onClose(); } },
);
// v1.4 commands
commands.push(
{ id: "integrity", icon: "🛡️", label: "Integrity Report", action: () => { navigate("/integrity"); onClose(); } },
{ id: "audit-log", icon: "📋", label: "Audit Log", action: () => { navigate("/audit-log"); onClose(); } },
{ id: "create-backup", icon: "💾", label: "Create Backup", action: () => { onClose(); } },
{ id: "verify-vault", icon: "🔐", label: "Verify Vault Checksums", action: () => { navigate("/integrity"); onClose(); } },
);
}
// Note matches

View file

@ -1,6 +1,7 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState } from "react";
import { useVault } from "../App";
import { buildGraph } from "../lib/commands";
import { detectClusters, clusterColors, type ClusterResult } from "../lib/clustering";
interface AnalyticsData {
totalNotes: number;
@ -8,21 +9,23 @@ interface AnalyticsData {
avgLinks: number;
orphans: { name: string }[];
mostConnected: { name: string; count: number }[];
clusters: ClusterResult;
clusterLabels: Map<number, string[]>;
}
/**
* GraphAnalytics Orphan detection, most-connected, graph stats.
* GraphAnalytics Orphan detection, most-connected, clusters, graph stats.
*/
export function GraphAnalytics() {
const { vaultPath, navigateToNote } = useVault();
const [data, setData] = useState<AnalyticsData | null>(null);
const [tab, setTab] = useState<'overview' | 'clusters'>('overview');
useEffect(() => {
if (!vaultPath) return;
buildGraph(vaultPath).then(graph => {
const linkCount = new Map<string, number>();
// Count connections per node
graph.nodes.forEach(n => linkCount.set(n.id, 0));
graph.edges.forEach(e => {
linkCount.set(e.source, (linkCount.get(e.source) || 0) + 1);
@ -45,12 +48,25 @@ export function GraphAnalytics() {
const totalLinks = graph.edges.length;
const avgLinks = graph.nodes.length > 0 ? totalLinks / graph.nodes.length : 0;
// Cluster detection
const clusters = detectClusters(graph);
const clusterLabels = new Map<number, string[]>();
for (const [clusterId, nodeIds] of clusters.clusters) {
const labels = nodeIds.map(id => {
const node = graph.nodes.find(n => n.id === id);
return node?.label || id.replace(".md", "");
});
clusterLabels.set(clusterId, labels);
}
setData({
totalNotes: graph.nodes.length,
totalLinks,
avgLinks,
orphans,
mostConnected,
clusters,
clusterLabels,
});
}).catch(() => { });
}, [vaultPath]);
@ -59,68 +75,121 @@ export function GraphAnalytics() {
return <div className="analytics-view"><div className="analytics-loading">Loading analytics</div></div>;
}
const colors = clusterColors(Math.max(data.clusters.clusterCount, 1));
return (
<div className="analytics-view">
<h2 className="analytics-title">📊 Graph Analytics</h2>
{/* Stats */}
<div className="analytics-stats">
<div className="analytics-stat">
<span className="stat-value">{data.totalNotes}</span>
<span className="stat-label">Notes</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.totalLinks}</span>
<span className="stat-label">Links</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.avgLinks.toFixed(1)}</span>
<span className="stat-label">Avg Links</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.orphans.length}</span>
<span className="stat-label">Orphans</span>
</div>
{/* Tab bar */}
<div className="analytics-tabs">
<button
className={`analytics-tab ${tab === 'overview' ? 'active' : ''}`}
onClick={() => setTab('overview')}
>
Overview
</button>
<button
className={`analytics-tab ${tab === 'clusters' ? 'active' : ''}`}
onClick={() => setTab('clusters')}
>
Clusters ({data.clusters.clusterCount})
</button>
</div>
{/* Most Connected */}
<div className="analytics-section">
<h3 className="analytics-section-title">🔗 Most Connected</h3>
<div className="analytics-list">
{data.mostConnected.map((item, i) => (
<div
key={item.name}
className="analytics-list-item"
onClick={() => navigateToNote(item.name)}
>
<span className="analytics-rank">#{i + 1}</span>
<span className="analytics-name">{item.name}</span>
<span className="analytics-count">{item.count} links</span>
<div className="analytics-bar" style={{ width: `${(item.count / (data.mostConnected[0]?.count || 1)) * 100}%` }} />
{tab === 'overview' && (
<>
{/* Stats */}
<div className="analytics-stats">
<div className="analytics-stat">
<span className="stat-value">{data.totalNotes}</span>
<span className="stat-label">Notes</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.totalLinks}</span>
<span className="stat-label">Links</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.avgLinks.toFixed(1)}</span>
<span className="stat-label">Avg Links</span>
</div>
<div className="analytics-stat">
<span className="stat-value">{data.orphans.length}</span>
<span className="stat-label">Orphans</span>
</div>
))}
</div>
</div>
{/* Orphans */}
<div className="analytics-section">
<h3 className="analytics-section-title">🏝 Orphan Notes ({data.orphans.length})</h3>
{data.orphans.length === 0 ? (
<p className="analytics-empty">No orphan notes every note is linked!</p>
) : (
<div className="analytics-orphan-grid">
{data.orphans.map(o => (
<button
key={o.name}
className="analytics-orphan-chip"
onClick={() => navigateToNote(o.name)}
>
{o.name}
</button>
))}
</div>
)}
</div>
{/* Most Connected */}
<div className="analytics-section">
<h3 className="analytics-section-title">🔗 Most Connected</h3>
<div className="analytics-list">
{data.mostConnected.map((item, i) => (
<div
key={item.name}
className="analytics-list-item"
onClick={() => navigateToNote(item.name)}
>
<span className="analytics-rank">#{i + 1}</span>
<span className="analytics-name">{item.name}</span>
<span className="analytics-count">{item.count} links</span>
<div className="analytics-bar" style={{ width: `${(item.count / (data.mostConnected[0]?.count || 1)) * 100}%` }} />
</div>
))}
</div>
</div>
{/* Orphans */}
<div className="analytics-section">
<h3 className="analytics-section-title">🏝 Orphan Notes ({data.orphans.length})</h3>
{data.orphans.length === 0 ? (
<p className="analytics-empty">No orphan notes every note is linked!</p>
) : (
<div className="analytics-orphan-grid">
{data.orphans.map(o => (
<button
key={o.name}
className="analytics-orphan-chip"
onClick={() => navigateToNote(o.name)}
>
{o.name}
</button>
))}
</div>
)}
</div>
</>
)}
{tab === 'clusters' && (
<div className="analytics-section">
{Array.from(data.clusterLabels.entries())
.sort((a, b) => b[1].length - a[1].length)
.map(([clusterId, labels]) => (
<div key={clusterId} className="cluster-group">
<h3 className="cluster-title">
<span
className="cluster-dot"
style={{ background: colors[clusterId % colors.length] }}
/>
Cluster {clusterId + 1}
<span className="cluster-count">{labels.length} notes</span>
</h3>
<div className="analytics-orphan-grid">
{labels.map(name => (
<button
key={name}
className="analytics-orphan-chip"
onClick={() => navigateToNote(name)}
style={{ borderColor: colors[clusterId % colors.length] }}
>
{name}
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1,326 +1,195 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Canvas,
CanvasProvider as _CanvasProvider,
registerNodeType,
ViewportControls,
} from "@blinksgg/canvas";
// @ts-ignore -- subpath export types not emitted
import { InMemoryStorageAdapter } from "@blinksgg/canvas/db";
import { useForceLayout, useFitToBounds, FitToBoundsMode } from "@blinksgg/canvas/hooks";
import { useVault } from "../App";
import { buildGraph, type GraphData, type GraphEdge } from "../lib/commands";
import { buildGraph, type GraphData } from "../lib/commands";
import { detectClusters, clusterColors } from "../lib/clustering";
import { NoteGraphNode } from "./NoteGraphNode";
const NODE_COLORS = [
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e",
"#06b6d4", "#a855f7", "#ec4899", "#14b8a6", "#ef4444",
];
// Cast to bypass dist/source type mismatch
const CanvasProviderAny = _CanvasProvider as any;
/* ── Force simulation types ─────────────────────────────── */
interface SimNode {
id: string;
label: string;
path: string;
x: number;
y: number;
vx: number;
vy: number;
radius: number;
color: string;
linkCount: number;
}
// Register custom node type
registerNodeType("note", NoteGraphNode as any);
/**
* GraphView Force-directed graph rendered with HTML5 Canvas.
* Nodes represent notes, edges represent wikilinks between them.
* GraphView Note graph powered by @blinksgg/canvas v3.0.
*/
export function GraphView() {
const { vaultPath } = useVault();
const navigate = useNavigate();
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [graphData, setGraphData] = useState<GraphData | null>(null);
const nodesRef = useRef<SimNode[]>([]);
const edgesRef = useRef<GraphEdge[]>([]);
const animRef = useRef<number>(0);
const panRef = useRef({ x: 0, y: 0 });
const zoomRef = useRef(1);
const dragRef = useRef<{ node: SimNode; offsetX: number; offsetY: number } | null>(null);
const isPanningRef = useRef(false);
const lastMouseRef = useRef({ x: 0, y: 0 });
const hoveredRef = useRef<SimNode | null>(null);
const [nodeCount, setNodeCount] = useState(0);
const [edgeCount, setEdgeCount] = useState(0);
const [adapterReady, setAdapterReady] = useState(false);
const [layout, setLayout] = useState<"force" | "tree" | "grid">("force");
const [search, setSearch] = useState("");
// Load graph data from backend
// Stable adapter instance
const adapter = useMemo(() => new InMemoryStorageAdapter(), []);
const graphId = `vault-${vaultPath || "default"}`;
// 1. Load graph from backend
useEffect(() => {
if (!vaultPath) return;
buildGraph(vaultPath).then(data => {
setGraphData(data);
setNodeCount(data.nodes.length);
setEdgeCount(data.edges.length);
}).catch(() => { });
buildGraph(vaultPath).then(setGraphData).catch(err => {
console.error("[GraphView] buildGraph failed:", err);
});
}, [vaultPath]);
// Initialize simulation when data arrives
// 2. Populate adapter with graph data, THEN allow canvas to mount
useEffect(() => {
if (!graphData) return;
const { nodes, edges } = graphData;
const simNodes: SimNode[] = nodes.map((n, i) => ({
id: n.id,
label: n.label,
path: n.path,
x: (Math.random() - 0.5) * 400,
y: (Math.random() - 0.5) * 400,
vx: 0,
vy: 0,
radius: Math.max(6, Math.min(20, 6 + n.link_count * 2)),
color: NODE_COLORS[i % NODE_COLORS.length],
linkCount: n.link_count,
}));
const populate = async () => {
const clusters = detectClusters(graphData);
const colors = clusterColors(Math.max(clusters.clusterCount, 1));
nodesRef.current = simNodes;
edgesRef.current = edges;
// Build node records for the adapter
const nodes = graphData.nodes.map((n) => {
const clusterId = clusters.assignments.get(n.id) ?? 0;
const radius = Math.max(6, Math.min(20, 6 + n.link_count * 2));
return {
id: n.id,
graph_id: graphId,
label: n.label,
node_type: "note",
ui_properties: {
x: (Math.random() - 0.5) * 800,
y: (Math.random() - 0.5) * 800,
width: Math.max(120, 80 + radius * 4),
height: 50,
},
data: {
path: n.path,
link_count: n.link_count,
color: colors[clusterId % colors.length],
tags: [],
cluster_id: clusterId,
},
};
});
// Center the view
panRef.current = { x: 0, y: 0 };
zoomRef.current = 1;
}, [graphData]);
// Build edge records
const edges = graphData.edges.map((e, i) => ({
id: `edge-${i}`,
graph_id: graphId,
source_node_id: e.source,
target_node_id: e.target,
data: {},
}));
// Convert screen coords to world coords
const screenToWorld = useCallback((sx: number, sy: number, canvas: HTMLCanvasElement) => {
const rect = canvas.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
return {
x: (sx - rect.left - cx - panRef.current.x) / zoomRef.current,
y: (sy - rect.top - cy - panRef.current.y) / zoomRef.current,
};
}, []);
// Find node at world position
const hitTest = useCallback((wx: number, wy: number): SimNode | null => {
// Iterate in reverse so topmost nodes are hit first
for (let i = nodesRef.current.length - 1; i >= 0; i--) {
const n = nodesRef.current[i];
const dx = wx - n.x;
const dy = wy - n.y;
if (dx * dx + dy * dy <= (n.radius + 4) * (n.radius + 4)) {
return n;
// Populate adapter via batch create
if (nodes.length > 0) {
await adapter.createNodes(graphId, nodes);
}
}
return null;
}, []);
// Animation loop — force simulation + rendering
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let running = true;
let coolingFactor = 1;
const tick = () => {
if (!running) return;
const nodes = nodesRef.current;
const edges = edgesRef.current;
if (nodes.length === 0) {
animRef.current = requestAnimationFrame(tick);
return;
if (edges.length > 0) {
await adapter.createEdges(graphId, edges);
}
// ── Force simulation step ──
const alpha = 0.3 * coolingFactor;
if (coolingFactor > 0.001) coolingFactor *= 0.995;
// Repulsion (charge)
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (150 * 150) / dist;
const fx = (dx / dist) * force * alpha;
const fy = (dy / dist) * force * alpha;
a.vx -= fx;
a.vy -= fy;
b.vx += fx;
b.vy += fy;
}
}
// Build node index for edge lookup
const nodeMap = new Map<string, SimNode>();
for (const n of nodes) nodeMap.set(n.id, n);
// Attraction (springs)
for (const edge of edges) {
const a = nodeMap.get(edge.source);
const b = nodeMap.get(edge.target);
if (!a || !b) continue;
let dx = b.x - a.x;
let dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - 100) * 0.05 * alpha;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
// Center gravity
for (const n of nodes) {
n.vx -= n.x * 0.01 * alpha;
n.vy -= n.y * 0.01 * alpha;
}
// Apply velocity + damping
for (const n of nodes) {
if (dragRef.current?.node === n) continue;
n.vx *= 0.6;
n.vy *= 0.6;
n.x += n.vx;
n.y += n.vy;
}
// ── Render ──
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.translate(w / 2 + panRef.current.x, h / 2 + panRef.current.y);
ctx.scale(zoomRef.current, zoomRef.current);
// Draw edges
ctx.strokeStyle = "rgba(255,255,255,0.08)";
ctx.lineWidth = 1;
for (const edge of edges) {
const a = nodeMap.get(edge.source);
const b = nodeMap.get(edge.target);
if (!a || !b) continue;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
// Draw nodes
const hovered = hoveredRef.current;
for (const n of nodes) {
const isHovered = n === hovered;
ctx.beginPath();
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
ctx.fillStyle = isHovered ? "#fff" : n.color;
ctx.fill();
if (isHovered) {
ctx.strokeStyle = n.color;
ctx.lineWidth = 2;
ctx.stroke();
}
// Label
ctx.fillStyle = isHovered ? "#fff" : "rgba(255,255,255,0.7)";
ctx.font = `${isHovered ? "bold " : ""}11px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(n.label, n.x, n.y + n.radius + 4, 120);
}
ctx.restore();
animRef.current = requestAnimationFrame(tick);
console.log(`[GraphView] Populated ${nodes.length} nodes, ${edges.length} edges`);
setAdapterReady(true);
};
animRef.current = requestAnimationFrame(tick);
populate();
}, [graphData, graphId, adapter]);
return () => {
running = false;
cancelAnimationFrame(animRef.current);
};
}, [graphData]);
const handleNodeClick = useCallback((nodeId: string) => {
if (!graphData) return;
const node = graphData.nodes.find(n => n.id === nodeId);
if (node) navigate(`/note/${encodeURIComponent(node.path)}`);
}, [graphData, navigate]);
// ── Mouse interaction handlers ──
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const world = screenToWorld(e.clientX, e.clientY, canvas);
const node = hitTest(world.x, world.y);
const renderNode = useCallback(({ node, isSelected }: any) => (
<NoteGraphNode nodeData={node} isSelected={isSelected} />
), []);
if (node) {
dragRef.current = { node, offsetX: world.x - node.x, offsetY: world.y - node.y };
} else {
isPanningRef.current = true;
}
lastMouseRef.current = { x: e.clientX, y: e.clientY };
}, [screenToWorld, hitTest]);
if (!graphData) {
return <div className="graph-loading">Loading graph</div>;
}
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
if (dragRef.current) {
const world = screenToWorld(e.clientX, e.clientY, canvas);
dragRef.current.node.x = world.x - dragRef.current.offsetX;
dragRef.current.node.y = world.y - dragRef.current.offsetY;
dragRef.current.node.vx = 0;
dragRef.current.node.vy = 0;
} else if (isPanningRef.current) {
panRef.current.x += e.clientX - lastMouseRef.current.x;
panRef.current.y += e.clientY - lastMouseRef.current.y;
} else {
// Hover detection
const world = screenToWorld(e.clientX, e.clientY, canvas);
const node = hitTest(world.x, world.y);
hoveredRef.current = node;
canvas.style.cursor = node ? "pointer" : "grab";
}
lastMouseRef.current = { x: e.clientX, y: e.clientY };
}, [screenToWorld, hitTest]);
const handleMouseUp = useCallback(() => {
if (dragRef.current) {
dragRef.current = null;
}
isPanningRef.current = false;
}, []);
const handleClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const world = screenToWorld(e.clientX, e.clientY, canvas);
const node = hitTest(world.x, world.y);
if (node) {
navigate(`/note/${encodeURIComponent(node.path)}`);
}
}, [screenToWorld, hitTest, navigate]);
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
zoomRef.current = Math.max(0.1, Math.min(5, zoomRef.current * factor));
}, []);
if (!adapterReady) {
return <div className="graph-loading">Building graph</div>;
}
return (
<div className="graph-canvas-wrapper" ref={containerRef}>
<div className="graph-header" style={{ position: "absolute", top: 12, left: 16, zIndex: 10, display: "flex", gap: 12, alignItems: "center" }}>
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>
{nodeCount} notes &middot; {edgeCount} links
</span>
<CanvasProviderAny adapter={adapter} graphId={graphId}>
<div className="graph-canvas-wrapper">
<div className="graph-toolbar">
<span className="graph-stats">
{graphData.nodes.length} notes · {graphData.edges.length} links
</span>
<LayoutButtons layout={layout} onLayoutChange={setLayout} />
<input
type="text"
placeholder="Search graph…"
value={search}
onChange={e => setSearch(e.target.value)}
className="graph-search-input"
/>
</div>
<Canvas renderNode={renderNode} minZoom={0.05} maxZoom={5}>
<ViewportControls />
<AutoLayout />
</Canvas>
</div>
<canvas
ref={canvasRef}
style={{ width: "100%", height: "100%", cursor: "grab" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onClick={handleClick}
onWheel={handleWheel}
/>
</CanvasProviderAny>
);
}
/** Applies force layout + fit once on mount */
function AutoLayout() {
const { applyForceLayout } = useForceLayout();
const { fitToBounds } = useFitToBounds();
const [applied, setApplied] = useState(false);
useEffect(() => {
if (applied) return;
const timer = setTimeout(async () => {
try {
await applyForceLayout();
fitToBounds(FitToBoundsMode.Graph, 60);
} catch (e) {
console.warn("[GraphView] Layout apply failed:", e);
}
setApplied(true);
}, 300);
return () => clearTimeout(timer);
}, [applied, applyForceLayout, fitToBounds]);
return null;
}
function LayoutButtons({ layout, onLayoutChange }: { layout: string; onLayoutChange: (l: any) => void }) {
const { applyForceLayout } = useForceLayout();
const { fitToBounds } = useFitToBounds();
const handleLayout = async (mode: string) => {
onLayoutChange(mode);
if (mode === "force") {
await applyForceLayout();
fitToBounds(FitToBoundsMode.Graph, 60);
}
};
return (
<div className="graph-layout-btns">
{(["force", "tree", "grid"] as const).map(m => (
<button
key={m}
className={`graph-layout-btn ${layout === m ? "active" : ""}`}
onClick={() => handleLayout(m)}
>
{m === "force" ? "⚡ Force" : m === "tree" ? "🌳 Tree" : "▦ Grid"}
</button>
))}
</div>
);
}

View file

@ -1,9 +1,9 @@
import { useState, useCallback } from "react";
import { useVault } from "../App";
import { exportVaultZip, importFolder } from "../lib/commands";
import { exportVaultZip, importFolder, exportSite } from "../lib/commands";
/**
* ImportExport Import notes from folders, export vault as ZIP.
* ImportExport Import notes from folders, export vault as ZIP, publish as site.
*/
export function ImportExport({ onClose }: { onClose: () => void }) {
const { vaultPath, refreshNotes } = useVault();
@ -39,6 +39,24 @@ export function ImportExport({ onClose }: { onClose: () => void }) {
setLoading(false);
}, [vaultPath, refreshNotes]);
const handlePublishSite = useCallback(async () => {
if (!vaultPath) return;
const notePathsInput = prompt("Note paths to publish (comma-separated, e.g. note1.md,folder/note2.md):");
if (!notePathsInput?.trim()) return;
const outputDir = prompt("Output directory for the site:", `${vaultPath}/../published-site`);
if (!outputDir?.trim()) return;
setLoading(true);
try {
const notePaths = notePathsInput.split(",").map(p => p.trim()).filter(Boolean);
const result = await exportSite(vaultPath, notePaths, outputDir.trim());
setStatus(`${result}`);
} catch (e: any) {
setStatus(`❌ Publish failed: ${e}`);
}
setLoading(false);
}, [vaultPath]);
return (
<div className="ie-overlay" onClick={onClose}>
<div className="ie-modal" onClick={e => e.stopPropagation()}>
@ -68,6 +86,18 @@ export function ImportExport({ onClose }: { onClose: () => void }) {
</button>
</div>
<div className="ie-divider" />
<div className="ie-section">
<h4 className="ie-section-title">Publish as Site</h4>
<p className="ie-desc">
Export selected notes as a browsable HTML micro-site with resolved wikilinks.
</p>
<button className="ie-action-btn" onClick={handlePublishSite} disabled={loading}>
🌐 Publish Site
</button>
</div>
{status && <div className="ie-status">{status}</div>}
</div>
</div>

View file

@ -0,0 +1,232 @@
import { useState, useEffect } from "react";
import { useVault } from "../App";
import {
scanIntegrity,
computeChecksums,
verifyChecksums,
findOrphanAttachments,
createBackup,
listBackups,
restoreBackup,
type IntegrityIssue,
type ChecksumMismatch,
type OrphanAttachment,
type BackupEntry,
} from "../lib/commands";
export default function IntegrityReport({ onClose }: { onClose: () => void }) {
const { vaultPath } = useVault();
const [issues, setIssues] = useState<IntegrityIssue[]>([]);
const [mismatches, setMismatches] = useState<ChecksumMismatch[]>([]);
const [orphans, setOrphans] = useState<OrphanAttachment[]>([]);
const [backups, setBackups] = useState<BackupEntry[]>([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<"scan" | "checksums" | "orphans" | "backups">("scan");
const [status, setStatus] = useState("");
useEffect(() => {
runScan();
loadBackups();
}, [vaultPath]);
const runScan = async () => {
if (!vaultPath) return;
setLoading(true);
setStatus("Scanning vault...");
try {
const result = await scanIntegrity(vaultPath);
setIssues(result);
setStatus(`Found ${result.length} issue(s)`);
} catch (e) {
setStatus(`Scan failed: ${e}`);
}
setLoading(false);
};
const runChecksumVerify = async () => {
if (!vaultPath) return;
setLoading(true);
setStatus("Computing checksums...");
try {
await computeChecksums(vaultPath);
const result = await verifyChecksums(vaultPath);
setMismatches(result);
setStatus(result.length === 0 ? "All checksums valid ✓" : `${result.length} mismatch(es) found`);
} catch (e) {
setStatus(`Checksum verification failed: ${e}`);
}
setLoading(false);
};
const runOrphanScan = async () => {
if (!vaultPath) return;
setLoading(true);
setStatus("Scanning attachments...");
try {
const result = await findOrphanAttachments(vaultPath);
setOrphans(result);
setStatus(result.length === 0 ? "No orphan attachments ✓" : `${result.length} orphan(s) found`);
} catch (e) {
setStatus(`Orphan scan failed: ${e}`);
}
setLoading(false);
};
const handleCreateBackup = async () => {
if (!vaultPath) return;
setLoading(true);
setStatus("Creating backup...");
try {
const name = await createBackup(vaultPath);
setStatus(`Backup created: ${name}`);
loadBackups();
} catch (e) {
setStatus(`Backup failed: ${e}`);
}
setLoading(false);
};
const loadBackups = async () => {
if (!vaultPath) return;
try {
const list = await listBackups(vaultPath);
setBackups(list);
} catch { /* ignore */ }
};
const handleRestore = async (name: string) => {
if (!vaultPath) return;
if (!confirm(`Restore from ${name}? This will overwrite current files.`)) return;
setLoading(true);
try {
const count = await restoreBackup(vaultPath, name);
setStatus(`Restored ${count} files from ${name}`);
} catch (e) {
setStatus(`Restore failed: ${e}`);
}
setLoading(false);
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
};
const severityIcon = (s: string) => s === "error" ? "🔴" : s === "warning" ? "🟡" : "🔵";
return (
<div className="integrity-report">
<div className="integrity-header">
<h3>🛡 Integrity Report</h3>
<button className="panel-close-btn" onClick={onClose}></button>
</div>
<div className="integrity-tabs">
{(["scan", "checksums", "orphans", "backups"] as const).map((tab) => (
<button
key={tab}
className={`integrity-tab ${activeTab === tab ? "active" : ""}`}
onClick={() => {
setActiveTab(tab);
if (tab === "checksums") runChecksumVerify();
if (tab === "orphans") runOrphanScan();
}}
>
{tab === "scan" ? "🔍 Scan" : tab === "checksums" ? "🔐 Checksums"
: tab === "orphans" ? "📎 Orphans" : "💾 Backups"}
</button>
))}
</div>
{status && <div className="integrity-status">{loading ? "⏳ " : ""}{status}</div>}
<div className="integrity-body">
{activeTab === "scan" && (
<>
<button className="integrity-action-btn" onClick={runScan} disabled={loading}>Re-scan Vault</button>
{issues.length === 0 && !loading && (
<div className="integrity-empty"> No issues found vault is clean</div>
)}
{issues.map((issue, i) => (
<div key={i} className="integrity-issue">
<span className="integrity-severity">{severityIcon(issue.severity)}</span>
<div className="integrity-issue-body">
<span className="integrity-issue-path">{issue.path}</span>
<span className="integrity-issue-desc">{issue.description}</span>
</div>
</div>
))}
</>
)}
{activeTab === "checksums" && (
<>
<button className="integrity-action-btn" onClick={runChecksumVerify} disabled={loading}>
Recompute & Verify
</button>
{mismatches.length === 0 && !loading && (
<div className="integrity-empty"> All checksums match</div>
)}
{mismatches.map((m, i) => (
<div key={i} className="integrity-issue">
<span className="integrity-severity"></span>
<div className="integrity-issue-body">
<span className="integrity-issue-path">{m.path}</span>
<span className="integrity-issue-desc">
Expected: {m.expected.slice(0, 12)} Got: {m.actual.slice(0, 12)}
</span>
</div>
</div>
))}
</>
)}
{activeTab === "orphans" && (
<>
<button className="integrity-action-btn" onClick={runOrphanScan} disabled={loading}>
Rescan Orphans
</button>
{orphans.length === 0 && !loading && (
<div className="integrity-empty"> No orphan attachments</div>
)}
{orphans.map((o, i) => (
<div key={i} className="integrity-issue">
<span className="integrity-severity">📎</span>
<div className="integrity-issue-body">
<span className="integrity-issue-path">{o.path}</span>
<span className="integrity-issue-desc">{formatSize(o.size)}</span>
</div>
</div>
))}
</>
)}
{activeTab === "backups" && (
<>
<button className="integrity-action-btn" onClick={handleCreateBackup} disabled={loading}>
Create Backup Now
</button>
{backups.length === 0 && !loading && (
<div className="integrity-empty">No backups yet</div>
)}
{backups.map((b, i) => (
<div key={i} className="integrity-issue">
<span className="integrity-severity">💾</span>
<div className="integrity-issue-body">
<span className="integrity-issue-path">{b.name}</span>
<span className="integrity-issue-desc">
{b.created} · {formatSize(b.size)}
</span>
</div>
<button className="integrity-restore-btn" onClick={() => handleRestore(b.name)}>
Restore
</button>
</div>
))}
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
import { useNavigate } from "react-router-dom";
/**
* NoteGraphNode Custom node component for the note graph.
* Shows title, tag pills, link count badge, and cluster color.
*/
export function NoteGraphNode({ nodeData, isSelected }: { nodeData: any; isSelected?: boolean }) {
const navigate = useNavigate();
const meta = nodeData.dbData ?? nodeData.data ?? {};
const label = nodeData.label || meta.label || "Untitled";
const tags: string[] = meta.tags || [];
const linkCount: number = meta.link_count ?? 0;
const color: string = meta.color || "#8b5cf6";
const path = meta.path || "";
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (path) navigate(`/note/${encodeURIComponent(path)}`);
};
return (
<div
className={`note-graph-node ${isSelected ? "selected" : ""}`}
style={{ "--node-color": color } as React.CSSProperties}
onDoubleClick={handleDoubleClick}
>
<div className="note-graph-node-color" style={{ background: color }} />
<div className="note-graph-node-body">
<span className="note-graph-node-title">{label}</span>
{tags.length > 0 && (
<div className="note-graph-node-tags">
{tags.slice(0, 3).map((t: string) => (
<span key={t} className="note-graph-node-tag">{t}</span>
))}
</div>
)}
</div>
{linkCount > 0 && (
<span className="note-graph-node-badge">{linkCount}</span>
)}
</div>
);
}

View file

@ -0,0 +1,74 @@
import { useState, useRef, useEffect } from 'react';
import { appendToInbox } from '../lib/commands';
interface QuickCaptureProps {
vaultPath: string;
onClose: () => void;
onCaptured?: () => void;
}
export default function QuickCapture({ vaultPath, onClose, onCaptured }: QuickCaptureProps) {
const [content, setContent] = useState('');
const [saving, setSaving] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleSave = async () => {
if (!content.trim()) return;
setSaving(true);
try {
await appendToInbox(vaultPath, content);
setContent('');
onCaptured?.();
onClose();
} catch (e) {
console.error('Failed to save to inbox:', e);
}
setSaving(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSave();
}
};
return (
<div className="quick-capture-overlay" onClick={onClose}>
<div
className="quick-capture-modal"
onClick={(e) => e.stopPropagation()}
id="quick-capture"
>
<div className="quick-capture-header">
<span className="quick-capture-title"> Quick Capture</span>
<span className="quick-capture-hint"> to save · Esc to close</span>
</div>
<textarea
ref={textareaRef}
className="quick-capture-input"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Jot down a fleeting thought..."
rows={4}
/>
<div className="quick-capture-footer">
<span className="quick-capture-dest"> _inbox.md</span>
<button
className="quick-capture-save-btn"
onClick={handleSave}
disabled={!content.trim() || saving}
>
{saving ? 'Saving...' : 'Capture'}
</button>
</div>
</div>
</div>
);
}

View file

@ -334,6 +334,20 @@ export function Sidebar() {
<span className="sidebar-action-icon">📊</span>
Analytics
</button>
<button
className={`sidebar-action ${location.pathname === "/integrity" ? "active" : ""}`}
onClick={() => navigate("/integrity")}
>
<span className="sidebar-action-icon">🛡</span>
Integrity
</button>
<button
className={`sidebar-action ${location.pathname === "/audit-log" ? "active" : ""}`}
onClick={() => navigate("/audit-log")}
>
<span className="sidebar-action-icon">📋</span>
Audit Log
</button>
</div>
{/* ── Search Results ── */}

View file

@ -1,9 +1,40 @@
import { useMemo } from "react";
import { useMemo, useEffect, useState } from "react";
import { getWordHistory } from "../lib/commands";
/**
* Sparkline Inline SVG mini chart for word count trends.
*/
function Sparkline({ data }: { data: number[] }) {
if (data.length < 2) return null;
const max = Math.max(...data, 1);
const w = 60;
const h = 16;
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
}).join(" ");
return (
<svg width={w} height={h} className="sparkline" viewBox={`0 0 ${w} ${h}`}>
<polyline
fill="none"
stroke="var(--accent)"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
points={points}
/>
</svg>
);
}
/**
* StatusBar Document statistics at bottom of editor.
*/
export function StatusBar({ content }: { content: string }) {
export function StatusBar({ content, vaultPath, notePath }: { content: string; vaultPath?: string; notePath?: string }) {
const [history, setHistory] = useState<number[]>([]);
const stats = useMemo(() => {
if (!content) return { words: 0, chars: 0, lines: 0, readTime: "0 min", headings: 0 };
@ -22,6 +53,14 @@ export function StatusBar({ content }: { content: string }) {
};
}, [content]);
useEffect(() => {
if (vaultPath && notePath) {
getWordHistory(vaultPath, notePath)
.then(setHistory)
.catch(() => setHistory([]));
}
}, [vaultPath, notePath]);
return (
<div className="status-bar">
<div className="status-bar-left">
@ -29,6 +68,11 @@ export function StatusBar({ content }: { content: string }) {
<span className="status-label">Words</span>
<span className="status-value">{stats.words.toLocaleString()}</span>
</span>
{history.length >= 2 && (
<span className="status-item" title="30-day word count trend">
<Sparkline data={history} />
</span>
)}
<span className="status-separator">·</span>
<span className="status-item">
<span className="status-label">Chars</span>

View file

@ -0,0 +1,100 @@
import { useState, useEffect } from 'react';
import { listTrash, restoreNote, emptyTrash, type TrashEntry } from '../lib/commands';
interface TrashPanelProps {
vaultPath: string;
onRestore?: (path: string) => void;
onClose: () => void;
}
export default function TrashPanel({ vaultPath, onRestore, onClose }: TrashPanelProps) {
const [items, setItems] = useState<TrashEntry[]>([]);
const [loading, setLoading] = useState(true);
const refresh = async () => {
setLoading(true);
try {
const entries = await listTrash(vaultPath);
setItems(entries);
} catch (e) {
console.error('Failed to list trash:', e);
}
setLoading(false);
};
useEffect(() => { refresh(); }, [vaultPath]);
const handleRestore = async (trashedName: string) => {
try {
const restoredPath = await restoreNote(vaultPath, trashedName);
onRestore?.(restoredPath);
refresh();
} catch (e) {
console.error('Failed to restore:', e);
}
};
const handleEmptyTrash = async () => {
if (!confirm('Permanently delete all trashed notes? This cannot be undone.')) return;
try {
await emptyTrash(vaultPath);
refresh();
} catch (e) {
console.error('Failed to empty trash:', e);
}
};
const formatTimestamp = (ts: string) => {
if (ts.length < 15) return ts;
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}`;
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
};
return (
<div className="trash-panel" id="trash-panel">
<div className="trash-panel-header">
<h3>🗑 Trash</h3>
<div className="trash-panel-actions">
{items.length > 0 && (
<button className="trash-empty-btn" onClick={handleEmptyTrash} title="Empty Trash">
Empty
</button>
)}
<button className="panel-close-btn" onClick={onClose}></button>
</div>
</div>
<div className="trash-panel-body">
{loading ? (
<div className="trash-empty-state">Loading...</div>
) : items.length === 0 ? (
<div className="trash-empty-state">Trash is empty</div>
) : (
<ul className="trash-list">
{items.map((item) => (
<li key={item.trashed_name} className="trash-item">
<div className="trash-item-info">
<span className="trash-item-name">{item.original_path.replace('.md', '')}</span>
<span className="trash-item-meta">
{formatTimestamp(item.timestamp)} · {formatSize(item.size)}
</span>
</div>
<button
className="trash-restore-btn"
onClick={() => handleRestore(item.trashed_name)}
title="Restore"
>
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View file

@ -1,25 +1,36 @@
import { useCallback } from "react";
import { useParams } from "react-router-dom";
import { Provider as JotaiProvider } from "jotai";
import { Canvas, CanvasStyleProvider, registerBuiltinCommands, ViewportControls } from "@blinksgg/canvas";
import {
Canvas,
CanvasProvider as _CanvasProvider,
ViewportControls,
} from "@blinksgg/canvas";
// @ts-ignore -- subpath export types not emitted
import { InMemoryStorageAdapter } from "@blinksgg/canvas/db";
import { useVault } from "../App";
import { saveCanvas } from "../lib/commands";
registerBuiltinCommands();
const CanvasProviderAny = _CanvasProvider as any;
const adapter = new InMemoryStorageAdapter();
/**
* WhiteboardView Freeform visual thinking canvas.
* WhiteboardView Freeform visual thinking canvas (v3.0 API).
*/
export function WhiteboardView() {
const { name } = useParams<{ name: string }>();
const { vaultPath } = useVault();
const handleSave = useCallback(async () => {
if (!vaultPath || !name) return;
await saveCanvas(vaultPath, name, JSON.stringify({ savedAt: new Date().toISOString() })).catch(() => {});
}, [vaultPath, name]);
const renderNode = useCallback(({ node, isSelected }: any) => {
const nodeType = node.dbData?.node_type || "card";
const nodeType = node.dbData?.node_type || node.data?.node_type || "card";
if (nodeType === "text") {
return (
<div className={`wb-text-node ${isSelected ? "selected" : ""}`}>
{node.label || node.dbData?.label}
{node.label || node.dbData?.label || node.data?.label}
</div>
);
}
@ -29,35 +40,24 @@ export function WhiteboardView() {
style={{ borderColor: node.color || "#8b5cf6" }}
>
<div className="wb-card-color" style={{ background: node.color || "#8b5cf6" }} />
<span className="wb-card-label">{node.label || node.dbData?.label}</span>
<span className="wb-card-label">{node.label || node.dbData?.label || node.data?.label}</span>
</div>
);
}, []);
const handleSave = useCallback(async () => {
if (!vaultPath || !name) return;
await saveCanvas(vaultPath, name, JSON.stringify({ savedAt: new Date().toISOString() })).catch(() => { });
}, [vaultPath, name]);
return (
<JotaiProvider>
<CanvasStyleProvider isDark={true}>
<div className="whiteboard-wrapper">
<div className="whiteboard-toolbar">
<span className="whiteboard-title">📋 {name || "Untitled"}</span>
<div className="whiteboard-actions">
<button className="wb-btn wb-save" onClick={handleSave}>💾 Save</button>
</div>
<CanvasProviderAny adapter={adapter} graphId={`whiteboard-${name}`}>
<div className="whiteboard-wrapper">
<div className="whiteboard-toolbar">
<span className="whiteboard-title">📋 {name || "Untitled"}</span>
<div className="whiteboard-actions">
<button className="wb-btn wb-save" onClick={handleSave}>💾 Save</button>
</div>
<Canvas
renderNode={renderNode}
minZoom={0.1}
maxZoom={5}
>
<ViewportControls />
</Canvas>
</div>
</CanvasStyleProvider>
</JotaiProvider>
<Canvas renderNode={renderNode} minZoom={0.1} maxZoom={5}>
<ViewportControls />
</Canvas>
</div>
</CanvasProviderAny>
);
}

File diff suppressed because it is too large Load diff

145
src/lib/clustering.ts Normal file
View file

@ -0,0 +1,145 @@
/**
* Simple Louvain-style community detection for graph clustering.
* Operates on the GraphData structure from commands.ts.
*/
import type { GraphData } from './commands';
export interface ClusterResult {
/** Map from node ID → cluster index */
assignments: Map<string, number>;
/** Number of clusters found */
clusterCount: number;
/** Cluster index → array of node IDs */
clusters: Map<number, string[]>;
}
/**
* Detect communities using a simplified Louvain method.
* Uses modularity optimization via greedy local moves.
*/
export function detectClusters(graph: GraphData): ClusterResult {
const nodeIds = graph.nodes.map(n => n.id);
const nodeIndex = new Map<string, number>();
nodeIds.forEach((id, i) => nodeIndex.set(id, i));
// Build adjacency list
const adj = new Map<number, Set<number>>();
for (let i = 0; i < nodeIds.length; i++) {
adj.set(i, new Set());
}
for (const edge of graph.edges) {
const s = nodeIndex.get(edge.source);
const t = nodeIndex.get(edge.target);
if (s !== undefined && t !== undefined) {
adj.get(s)!.add(t);
adj.get(t)!.add(s);
}
}
const n = nodeIds.length;
if (n === 0) {
return { assignments: new Map(), clusterCount: 0, clusters: new Map() };
}
// Initialize: each node is its own community
const community = new Array<number>(n);
for (let i = 0; i < n; i++) community[i] = i;
const totalEdges = graph.edges.length || 1;
const m2 = totalEdges * 2;
// Degree of each node
const degree = new Array<number>(n).fill(0);
for (let i = 0; i < n; i++) {
degree[i] = adj.get(i)!.size;
}
// Sum of degrees for each community
const sigmaTot = new Array<number>(n).fill(0);
for (let i = 0; i < n; i++) sigmaTot[i] = degree[i];
// Iterate greedy local moves
let improved = true;
let iterations = 0;
const maxIterations = 20;
while (improved && iterations < maxIterations) {
improved = false;
iterations++;
for (let i = 0; i < n; i++) {
const currentComm = community[i];
const neighbors = adj.get(i)!;
const ki = degree[i];
// Count edges to each neighboring community
const commEdges = new Map<number, number>();
for (const neighbor of neighbors) {
const nc = community[neighbor];
commEdges.set(nc, (commEdges.get(nc) || 0) + 1);
}
// Modularity gain of removing node from current community
const kiIn = commEdges.get(currentComm) || 0;
sigmaTot[currentComm] -= ki;
let bestComm = currentComm;
let bestGain = 0;
for (const [comm, edgesToComm] of commEdges) {
const gain = edgesToComm / totalEdges - (sigmaTot[comm] * ki) / (m2 * totalEdges);
const lossFromCurrent = kiIn / totalEdges - (sigmaTot[currentComm] * ki) / (m2 * totalEdges);
const deltaQ = gain - lossFromCurrent;
if (deltaQ > bestGain) {
bestGain = deltaQ;
bestComm = comm;
}
}
sigmaTot[currentComm] += ki;
if (bestComm !== currentComm) {
sigmaTot[currentComm] -= ki;
sigmaTot[bestComm] += ki;
community[i] = bestComm;
improved = true;
}
}
}
// Normalize cluster IDs to 0..N
const uniqueComms = [...new Set(community)];
const commMap = new Map<number, number>();
uniqueComms.forEach((c, i) => commMap.set(c, i));
const assignments = new Map<string, number>();
const clusters = new Map<number, string[]>();
for (let i = 0; i < n; i++) {
const clusterId = commMap.get(community[i])!;
const nodeId = nodeIds[i];
assignments.set(nodeId, clusterId);
if (!clusters.has(clusterId)) clusters.set(clusterId, []);
clusters.get(clusterId)!.push(nodeId);
}
return {
assignments,
clusterCount: uniqueComms.length,
clusters,
};
}
/** Generate N visually distinct colors using golden angle distribution */
export function clusterColors(count: number): string[] {
const colors: string[] = [];
const goldenAngle = 137.508;
for (let i = 0; i < count; i++) {
const hue = (i * goldenAngle) % 360;
colors.push(`hsl(${hue}, 70%, 65%)`);
}
return colors;
}

View file

@ -391,3 +391,175 @@ export async function getPinned(vaultPath: string): Promise<string[]> {
export async function setPinned(vaultPath: string, pinned: string[]): Promise<void> {
return invoke<void>("set_pinned", { vaultPath, pinned });
}
/* ── v1.3 Commands ─────────────────────────────────────── */
// Federated Search
export interface FederatedResult {
vault_name: string;
vault_path: string;
note_path: string;
note_name: string;
excerpt: string;
score: number;
}
export async function searchMultiVault(query: string, vaultPaths: string[]): Promise<FederatedResult[]> {
return invoke<FederatedResult[]>("search_multi_vault", { query, vaultPaths });
}
// Note Types
export interface NoteType {
name: string;
icon: string;
fields: string[];
template: string;
}
export async function listNoteTypes(vaultPath: string): Promise<NoteType[]> {
return invoke<NoteType[]>("list_note_types", { vaultPath });
}
export async function createFromType(vaultPath: string, typeName: string, title: string): Promise<string> {
return invoke<string>("create_from_type", { vaultPath, typeName, title });
}
// Reading List
export interface ReadingItem {
note_path: string;
status: 'unread' | 'reading' | 'finished';
progress: number;
added_at: string;
}
export async function getReadingList(vaultPath: string): Promise<string> {
return invoke<string>("get_reading_list", { vaultPath });
}
export async function setReadingList(vaultPath: string, data: string): Promise<void> {
return invoke("set_reading_list", { vaultPath, data });
}
// Plugins
export interface PluginInfo {
name: string;
filename: string;
enabled: boolean;
hooks: string[];
}
export async function listPlugins(vaultPath: string): Promise<PluginInfo[]> {
return invoke<PluginInfo[]>("list_plugins", { vaultPath });
}
export async function togglePlugin(vaultPath: string, name: string, enabled: boolean): Promise<void> {
return invoke("toggle_plugin", { vaultPath, name, enabled });
}
// Vault Registry
export async function getVaultRegistry(): Promise<string[]> {
return invoke<string[]>("get_vault_registry", {});
}
export async function setVaultRegistry(vaults: string[]): Promise<void> {
return invoke("set_vault_registry", { vaults });
}
// RSS Export
export async function exportRss(vaultPath: string, outputDir: string, feedTitle: string, feedUrl: string): Promise<string> {
return invoke<string>("export_rss", { vaultPath, outputDir, feedTitle, feedUrl });
}
// AI Summary
export async function generateSummary(vaultPath: string, notePath: string): Promise<string> {
return invoke<string>("generate_summary", { vaultPath, notePath });
}
export async function getAiConfig(vaultPath: string): Promise<string> {
return invoke<string>("get_ai_config", { vaultPath });
}
export async function setAiConfig(vaultPath: string, config: string): Promise<void> {
return invoke("set_ai_config", { vaultPath, config });
}
/* ── v1.4 Commands ─────────────────────────────────────── */
// Content Checksums
export interface ChecksumMismatch {
path: string;
expected: string;
actual: string;
}
export async function computeChecksums(vaultPath: string): Promise<number> {
return invoke<number>("compute_checksums", { vaultPath });
}
export async function verifyChecksums(vaultPath: string): Promise<ChecksumMismatch[]> {
return invoke<ChecksumMismatch[]>("verify_checksums", { vaultPath });
}
// Vault Integrity Scanner
export interface IntegrityIssue {
severity: string;
category: string;
path: string;
description: string;
}
export async function scanIntegrity(vaultPath: string): Promise<IntegrityIssue[]> {
return invoke<IntegrityIssue[]>("scan_integrity", { vaultPath });
}
// Backups
export interface BackupEntry {
name: string;
size: number;
created: string;
}
export async function createBackup(vaultPath: string): Promise<string> {
return invoke<string>("create_backup", { vaultPath });
}
export async function listBackups(vaultPath: string): Promise<BackupEntry[]> {
return invoke<BackupEntry[]>("list_backups", { vaultPath });
}
export async function restoreBackup(vaultPath: string, backupName: string): Promise<number> {
return invoke<number>("restore_backup", { vaultPath, backupName });
}
// WAL
export interface WalEntry {
timestamp: string;
operation: string;
path: string;
content_hash: string;
status: string;
}
export async function walStatus(vaultPath: string): Promise<WalEntry[]> {
return invoke<WalEntry[]>("wal_status", { vaultPath });
}
export async function walRecover(vaultPath: string): Promise<number> {
return invoke<number>("wal_recover", { vaultPath });
}
// Conflict Detection
export async function checkConflict(vaultPath: string, relativePath: string, expectedMtime: number): Promise<string> {
return invoke<string>("check_conflict", { vaultPath, relativePath, expectedMtime });
}
// Frontmatter Validation
export interface FrontmatterWarning {
line: number;
message: string;
}
export async function validateFrontmatter(content: string): Promise<FrontmatterWarning[]> {
return invoke<FrontmatterWarning[]>("validate_frontmatter", { content });
}
// Orphan Attachments
export interface OrphanAttachment {
path: string;
size: number;
}
export async function findOrphanAttachments(vaultPath: string): Promise<OrphanAttachment[]> {
return invoke<OrphanAttachment[]>("find_orphan_attachments", { vaultPath });
}
// Audit Log
export interface AuditEntry {
timestamp: string;
operation: string;
path: string;
detail: string;
}
export async function getAuditLog(vaultPath: string, limit: number): Promise<AuditEntry[]> {
return invoke<AuditEntry[]>("get_audit_log", { vaultPath, limit });
}

View file

@ -27,5 +27,11 @@ export default defineConfig(async () => ({
watch: {
ignored: ["**/src-tauri/**", "**/vault/**"],
},
fs: {
allow: [
".",
path.resolve(__dirname, "../blinksgg/gg-antifragile"),
],
},
},
}));