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:
parent
bf4ef86874
commit
c6ce0b24d5
31 changed files with 5368 additions and 2248 deletions
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -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/).
|
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
|
## [1.0.0] — 2026-03-09
|
||||||
|
|
||||||
### 🎉 First Stable Release
|
### 🎉 First Stable Release
|
||||||
|
|
|
||||||
83
package-lock.json
generated
83
package-lock.json
generated
|
|
@ -1,14 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "graph-notes",
|
"name": "graph-notes",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "graph-notes",
|
"name": "graph-notes",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"dependencies": {
|
"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/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
"@tauri-apps/plugin-fs": "^2",
|
"@tauri-apps/plugin-fs": "^2",
|
||||||
|
|
@ -16,8 +17,10 @@
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-types": "^0.24.8",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
|
"jotai-family": "^1.0.1",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|
@ -38,14 +41,14 @@
|
||||||
},
|
},
|
||||||
"../blinksgg/gg-antifragile/packages/canvas": {
|
"../blinksgg/gg-antifragile/packages/canvas": {
|
||||||
"name": "@blinksgg/canvas",
|
"name": "@blinksgg/canvas",
|
||||||
"version": "0.13.0",
|
"version": "3.0.0",
|
||||||
"extraneous": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.49.5",
|
"@supabase/supabase-js": "^2.49.5",
|
||||||
"@use-gesture/react": "^10.3.1",
|
"@use-gesture/react": "^10.3.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
"graphology-types": "^0.24.8"
|
"graphology-types": "^0.24.8",
|
||||||
|
"jotai-family": "^1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.29.0",
|
"@babel/core": "^7.29.0",
|
||||||
|
|
@ -63,9 +66,11 @@
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
"esbuild-plugin-babel": "^0.2.3",
|
"esbuild-plugin-babel": "^0.2.3",
|
||||||
|
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"jotai": "^2.6.0",
|
"jotai": "^2.6.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|
@ -81,14 +86,31 @@
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
"jotai": "^2.6.0",
|
"jotai": "^2.6.0",
|
||||||
"jotai-tanstack-query": "*",
|
"react": "^19.2.0",
|
||||||
"react": "^19.0.0",
|
"react-dom": "^19.2.0"
|
||||||
"react-dom": "^19.0.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": {
|
"../space-operator/gg/packages/canvas": {
|
||||||
"name": "@blinksgg/canvas",
|
"name": "@blinksgg/canvas",
|
||||||
"version": "0.35.0",
|
"version": "0.35.0",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.49.5",
|
"@supabase/supabase-js": "^2.49.5",
|
||||||
"@use-gesture/react": "^10.3.1",
|
"@use-gesture/react": "^10.3.1",
|
||||||
|
|
@ -432,7 +454,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@blinksgg/canvas": {
|
"node_modules/@blinksgg/canvas": {
|
||||||
"resolved": "../space-operator/gg/packages/canvas",
|
"resolved": "../blinksgg/gg-antifragile/packages/canvas",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@braintree/sanitize-url": {
|
"node_modules/@braintree/sanitize-url": {
|
||||||
|
|
@ -1627,6 +1649,32 @@
|
||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"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": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.10.1",
|
"version": "2.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||||
|
|
@ -3109,8 +3157,7 @@
|
||||||
"version": "0.24.8",
|
"version": "0.24.8",
|
||||||
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
|
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
|
||||||
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
|
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/hachure-fill": {
|
"node_modules/hachure-fill": {
|
||||||
"version": "0.5.2",
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "graph-notes",
|
"name": "graph-notes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
"@tauri-apps/plugin-fs": "^2",
|
"@tauri-apps/plugin-fs": "^2",
|
||||||
|
|
@ -18,8 +19,10 @@
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-types": "^0.24.8",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
|
"jotai-family": "^1.0.1",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|
|
||||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
|
|
@ -1473,7 +1473,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "graph-notes"
|
name = "graph-notes"
|
||||||
version = "1.0.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
@ -1483,6 +1483,7 @@ dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "graph-notes"
|
name = "graph-notes"
|
||||||
version = "1.0.0"
|
version = "1.5.0"
|
||||||
description = "A graph-based note-taking app"
|
description = "A graph-based note-taking app"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
@ -27,3 +27,4 @@ argon2 = "0.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
zip = "2"
|
zip = "2"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
|
||||||
79
src-tauri/src/crypto.rs
Normal file
79
src-tauri/src/crypto.rs
Normal 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, ¬e_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, ¬e_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, ¬e_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
123
src-tauri/src/export.rs
Normal 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, ¬e_path)?;
|
||||||
|
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||||
|
let title = Path::new(¬e_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: ®ex::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
80
src-tauri/src/git.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
2017
src-tauri/src/lib.rs
2017
src-tauri/src/lib.rs
File diff suppressed because it is too large
Load diff
1490
src-tauri/src/notes.rs
Normal file
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
117
src-tauri/src/srs.rs
Normal 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
758
src-tauri/src/state.rs
Normal 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, ¬e_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(¬e_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(¬e_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)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Graph Notes",
|
"productName": "Graph Notes",
|
||||||
"version": "1.0.0",
|
"version": "1.5.0",
|
||||||
"identifier": "com.graphnotes.app",
|
"identifier": "com.graphnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import { DatabaseView } from "./components/DatabaseView";
|
||||||
import { GitPanel } from "./components/GitPanel";
|
import { GitPanel } from "./components/GitPanel";
|
||||||
import { TimelineView } from "./components/TimelineView";
|
import { TimelineView } from "./components/TimelineView";
|
||||||
import { GraphAnalytics } from "./components/GraphAnalytics";
|
import { GraphAnalytics } from "./components/GraphAnalytics";
|
||||||
|
import IntegrityReport from "./components/IntegrityReport";
|
||||||
|
import AuditLog from "./components/AuditLog";
|
||||||
import {
|
import {
|
||||||
listNotes,
|
listNotes,
|
||||||
readNote,
|
readNote,
|
||||||
|
|
@ -389,6 +391,8 @@ export default function App() {
|
||||||
<Route path="/database" element={<DatabaseView />} />
|
<Route path="/database" element={<DatabaseView />} />
|
||||||
<Route path="/timeline" element={<TimelineView />} />
|
<Route path="/timeline" element={<TimelineView />} />
|
||||||
<Route path="/analytics" element={<GraphAnalytics />} />
|
<Route path="/analytics" element={<GraphAnalytics />} />
|
||||||
|
<Route path="/integrity" element={<IntegrityReport onClose={() => navigate('/')} />} />
|
||||||
|
<Route path="/audit-log" element={<AuditLog onClose={() => navigate('/')} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<CommandPalette open={cmdPaletteOpen} onClose={() => setCmdPaletteOpen(false)} />
|
<CommandPalette open={cmdPaletteOpen} onClose={() => setCmdPaletteOpen(false)} />
|
||||||
|
|
|
||||||
85
src/components/AuditLog.tsx
Normal file
85
src/components/AuditLog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/BookmarksPanel.tsx
Normal file
79
src/components/BookmarksPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -184,6 +184,14 @@ export function CommandPalette({ open, onClose }: { open: boolean; onClose: () =
|
||||||
{ id: "import-export", icon: "📦", label: "Import / Export", action: () => { onClose(); } },
|
{ id: "import-export", icon: "📦", label: "Import / Export", action: () => { onClose(); } },
|
||||||
{ id: "shortcuts", icon: "⌨️", label: "Keyboard Shortcuts", 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
|
// Note matches
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useVault } from "../App";
|
import { useVault } from "../App";
|
||||||
import { buildGraph } from "../lib/commands";
|
import { buildGraph } from "../lib/commands";
|
||||||
|
import { detectClusters, clusterColors, type ClusterResult } from "../lib/clustering";
|
||||||
|
|
||||||
interface AnalyticsData {
|
interface AnalyticsData {
|
||||||
totalNotes: number;
|
totalNotes: number;
|
||||||
|
|
@ -8,21 +9,23 @@ interface AnalyticsData {
|
||||||
avgLinks: number;
|
avgLinks: number;
|
||||||
orphans: { name: string }[];
|
orphans: { name: string }[];
|
||||||
mostConnected: { name: string; count: number }[];
|
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() {
|
export function GraphAnalytics() {
|
||||||
const { vaultPath, navigateToNote } = useVault();
|
const { vaultPath, navigateToNote } = useVault();
|
||||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||||
|
const [tab, setTab] = useState<'overview' | 'clusters'>('overview');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!vaultPath) return;
|
if (!vaultPath) return;
|
||||||
buildGraph(vaultPath).then(graph => {
|
buildGraph(vaultPath).then(graph => {
|
||||||
const linkCount = new Map<string, number>();
|
const linkCount = new Map<string, number>();
|
||||||
|
|
||||||
// Count connections per node
|
|
||||||
graph.nodes.forEach(n => linkCount.set(n.id, 0));
|
graph.nodes.forEach(n => linkCount.set(n.id, 0));
|
||||||
graph.edges.forEach(e => {
|
graph.edges.forEach(e => {
|
||||||
linkCount.set(e.source, (linkCount.get(e.source) || 0) + 1);
|
linkCount.set(e.source, (linkCount.get(e.source) || 0) + 1);
|
||||||
|
|
@ -45,12 +48,25 @@ export function GraphAnalytics() {
|
||||||
const totalLinks = graph.edges.length;
|
const totalLinks = graph.edges.length;
|
||||||
const avgLinks = graph.nodes.length > 0 ? totalLinks / graph.nodes.length : 0;
|
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({
|
setData({
|
||||||
totalNotes: graph.nodes.length,
|
totalNotes: graph.nodes.length,
|
||||||
totalLinks,
|
totalLinks,
|
||||||
avgLinks,
|
avgLinks,
|
||||||
orphans,
|
orphans,
|
||||||
mostConnected,
|
mostConnected,
|
||||||
|
clusters,
|
||||||
|
clusterLabels,
|
||||||
});
|
});
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
}, [vaultPath]);
|
}, [vaultPath]);
|
||||||
|
|
@ -59,10 +75,30 @@ export function GraphAnalytics() {
|
||||||
return <div className="analytics-view"><div className="analytics-loading">Loading analytics…</div></div>;
|
return <div className="analytics-view"><div className="analytics-loading">Loading analytics…</div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colors = clusterColors(Math.max(data.clusters.clusterCount, 1));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="analytics-view">
|
<div className="analytics-view">
|
||||||
<h2 className="analytics-title">📊 Graph Analytics</h2>
|
<h2 className="analytics-title">📊 Graph Analytics</h2>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<>
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="analytics-stats">
|
<div className="analytics-stats">
|
||||||
<div className="analytics-stat">
|
<div className="analytics-stat">
|
||||||
|
|
@ -121,6 +157,39 @@ export function GraphAnalytics() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { 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 = [
|
// Cast to bypass dist/source type mismatch
|
||||||
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e",
|
const CanvasProviderAny = _CanvasProvider as any;
|
||||||
"#06b6d4", "#a855f7", "#ec4899", "#14b8a6", "#ef4444",
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ── Force simulation types ─────────────────────────────── */
|
// Register custom node type
|
||||||
interface SimNode {
|
registerNodeType("note", NoteGraphNode as any);
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
path: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
radius: number;
|
|
||||||
color: string;
|
|
||||||
linkCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GraphView — Force-directed graph rendered with HTML5 Canvas.
|
* GraphView — Note graph powered by @blinksgg/canvas v3.0.
|
||||||
* Nodes represent notes, edges represent wikilinks between them.
|
|
||||||
*/
|
*/
|
||||||
export function GraphView() {
|
export function GraphView() {
|
||||||
const { vaultPath } = useVault();
|
const { vaultPath } = useVault();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [graphData, setGraphData] = useState<GraphData | null>(null);
|
const [graphData, setGraphData] = useState<GraphData | null>(null);
|
||||||
const nodesRef = useRef<SimNode[]>([]);
|
const [adapterReady, setAdapterReady] = useState(false);
|
||||||
const edgesRef = useRef<GraphEdge[]>([]);
|
const [layout, setLayout] = useState<"force" | "tree" | "grid">("force");
|
||||||
const animRef = useRef<number>(0);
|
const [search, setSearch] = useState("");
|
||||||
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);
|
|
||||||
|
|
||||||
// Load graph data from backend
|
// Stable adapter instance
|
||||||
|
const adapter = useMemo(() => new InMemoryStorageAdapter(), []);
|
||||||
|
const graphId = `vault-${vaultPath || "default"}`;
|
||||||
|
|
||||||
|
// 1. Load graph from backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!vaultPath) return;
|
if (!vaultPath) return;
|
||||||
buildGraph(vaultPath).then(data => {
|
buildGraph(vaultPath).then(setGraphData).catch(err => {
|
||||||
setGraphData(data);
|
console.error("[GraphView] buildGraph failed:", err);
|
||||||
setNodeCount(data.nodes.length);
|
});
|
||||||
setEdgeCount(data.edges.length);
|
|
||||||
}).catch(() => { });
|
|
||||||
}, [vaultPath]);
|
}, [vaultPath]);
|
||||||
|
|
||||||
// Initialize simulation when data arrives
|
// 2. Populate adapter with graph data, THEN allow canvas to mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!graphData) return;
|
if (!graphData) return;
|
||||||
|
|
||||||
const { nodes, edges } = graphData;
|
const populate = async () => {
|
||||||
const simNodes: SimNode[] = nodes.map((n, i) => ({
|
const clusters = detectClusters(graphData);
|
||||||
|
const colors = clusterColors(Math.max(clusters.clusterCount, 1));
|
||||||
|
|
||||||
|
// 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,
|
id: n.id,
|
||||||
|
graph_id: graphId,
|
||||||
label: n.label,
|
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,
|
path: n.path,
|
||||||
x: (Math.random() - 0.5) * 400,
|
link_count: n.link_count,
|
||||||
y: (Math.random() - 0.5) * 400,
|
color: colors[clusterId % colors.length],
|
||||||
vx: 0,
|
tags: [],
|
||||||
vy: 0,
|
cluster_id: clusterId,
|
||||||
radius: Math.max(6, Math.min(20, 6 + n.link_count * 2)),
|
},
|
||||||
color: NODE_COLORS[i % NODE_COLORS.length],
|
};
|
||||||
linkCount: n.link_count,
|
});
|
||||||
|
|
||||||
|
// 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: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
nodesRef.current = simNodes;
|
// Populate adapter via batch create
|
||||||
edgesRef.current = edges;
|
if (nodes.length > 0) {
|
||||||
|
await adapter.createNodes(graphId, nodes);
|
||||||
// Center the view
|
|
||||||
panRef.current = { x: 0, y: 0 };
|
|
||||||
zoomRef.current = 1;
|
|
||||||
}, [graphData]);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
if (edges.length > 0) {
|
||||||
return null;
|
await adapter.createEdges(graphId, edges);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Force simulation step ──
|
console.log(`[GraphView] Populated ${nodes.length} nodes, ${edges.length} edges`);
|
||||||
const alpha = 0.3 * coolingFactor;
|
setAdapterReady(true);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
animRef.current = requestAnimationFrame(tick);
|
populate();
|
||||||
|
}, [graphData, graphId, adapter]);
|
||||||
|
|
||||||
return () => {
|
const handleNodeClick = useCallback((nodeId: string) => {
|
||||||
running = false;
|
if (!graphData) return;
|
||||||
cancelAnimationFrame(animRef.current);
|
const node = graphData.nodes.find(n => n.id === nodeId);
|
||||||
};
|
if (node) navigate(`/note/${encodeURIComponent(node.path)}`);
|
||||||
}, [graphData]);
|
}, [graphData, navigate]);
|
||||||
|
|
||||||
// ── Mouse interaction handlers ──
|
const renderNode = useCallback(({ node, isSelected }: any) => (
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
<NoteGraphNode nodeData={node} isSelected={isSelected} />
|
||||||
const canvas = canvasRef.current;
|
), []);
|
||||||
if (!canvas) return;
|
|
||||||
const world = screenToWorld(e.clientX, e.clientY, canvas);
|
|
||||||
const node = hitTest(world.x, world.y);
|
|
||||||
|
|
||||||
if (node) {
|
if (!graphData) {
|
||||||
dragRef.current = { node, offsetX: world.x - node.x, offsetY: world.y - node.y };
|
return <div className="graph-loading">Loading graph…</div>;
|
||||||
} else {
|
|
||||||
isPanningRef.current = true;
|
|
||||||
}
|
}
|
||||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
}, [screenToWorld, hitTest]);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
if (!adapterReady) {
|
||||||
const canvas = canvasRef.current;
|
return <div className="graph-loading">Building graph…</div>;
|
||||||
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));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="graph-canvas-wrapper" ref={containerRef}>
|
<CanvasProviderAny adapter={adapter} graphId={graphId}>
|
||||||
<div className="graph-header" style={{ position: "absolute", top: 12, left: 16, zIndex: 10, display: "flex", gap: 12, alignItems: "center" }}>
|
<div className="graph-canvas-wrapper">
|
||||||
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
<div className="graph-toolbar">
|
||||||
{nodeCount} notes · {edgeCount} links
|
<span className="graph-stats">
|
||||||
|
{graphData.nodes.length} notes · {graphData.edges.length} links
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<LayoutButtons layout={layout} onLayoutChange={setLayout} />
|
||||||
<canvas
|
<input
|
||||||
ref={canvasRef}
|
type="text"
|
||||||
style={{ width: "100%", height: "100%", cursor: "grab" }}
|
placeholder="Search graph…"
|
||||||
onMouseDown={handleMouseDown}
|
value={search}
|
||||||
onMouseMove={handleMouseMove}
|
onChange={e => setSearch(e.target.value)}
|
||||||
onMouseUp={handleMouseUp}
|
className="graph-search-input"
|
||||||
onMouseLeave={handleMouseUp}
|
|
||||||
onClick={handleClick}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Canvas renderNode={renderNode} minZoom={0.05} maxZoom={5}>
|
||||||
|
<ViewportControls />
|
||||||
|
<AutoLayout />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useVault } from "../App";
|
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 }) {
|
export function ImportExport({ onClose }: { onClose: () => void }) {
|
||||||
const { vaultPath, refreshNotes } = useVault();
|
const { vaultPath, refreshNotes } = useVault();
|
||||||
|
|
@ -39,6 +39,24 @@ export function ImportExport({ onClose }: { onClose: () => void }) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [vaultPath, refreshNotes]);
|
}, [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 (
|
return (
|
||||||
<div className="ie-overlay" onClick={onClose}>
|
<div className="ie-overlay" onClick={onClose}>
|
||||||
<div className="ie-modal" onClick={e => e.stopPropagation()}>
|
<div className="ie-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
|
@ -68,6 +86,18 @@ export function ImportExport({ onClose }: { onClose: () => void }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>}
|
{status && <div className="ie-status">{status}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
232
src/components/IntegrityReport.tsx
Normal file
232
src/components/IntegrityReport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/NoteGraphNode.tsx
Normal file
43
src/components/NoteGraphNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/QuickCapture.tsx
Normal file
74
src/components/QuickCapture.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -334,6 +334,20 @@ export function Sidebar() {
|
||||||
<span className="sidebar-action-icon">📊</span>
|
<span className="sidebar-action-icon">📊</span>
|
||||||
Analytics
|
Analytics
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── Search Results ── */}
|
{/* ── Search Results ── */}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* 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(() => {
|
const stats = useMemo(() => {
|
||||||
if (!content) return { words: 0, chars: 0, lines: 0, readTime: "0 min", headings: 0 };
|
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]);
|
}, [content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (vaultPath && notePath) {
|
||||||
|
getWordHistory(vaultPath, notePath)
|
||||||
|
.then(setHistory)
|
||||||
|
.catch(() => setHistory([]));
|
||||||
|
}
|
||||||
|
}, [vaultPath, notePath]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="status-bar">
|
<div className="status-bar">
|
||||||
<div className="status-bar-left">
|
<div className="status-bar-left">
|
||||||
|
|
@ -29,6 +68,11 @@ export function StatusBar({ content }: { content: string }) {
|
||||||
<span className="status-label">Words</span>
|
<span className="status-label">Words</span>
|
||||||
<span className="status-value">{stats.words.toLocaleString()}</span>
|
<span className="status-value">{stats.words.toLocaleString()}</span>
|
||||||
</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-separator">·</span>
|
||||||
<span className="status-item">
|
<span className="status-item">
|
||||||
<span className="status-label">Chars</span>
|
<span className="status-label">Chars</span>
|
||||||
|
|
|
||||||
100
src/components/TrashPanel.tsx
Normal file
100
src/components/TrashPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,36 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
import {
|
||||||
import { Canvas, CanvasStyleProvider, registerBuiltinCommands, ViewportControls } from "@blinksgg/canvas";
|
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 { useVault } from "../App";
|
||||||
import { saveCanvas } from "../lib/commands";
|
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() {
|
export function WhiteboardView() {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const { vaultPath } = useVault();
|
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 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") {
|
if (nodeType === "text") {
|
||||||
return (
|
return (
|
||||||
<div className={`wb-text-node ${isSelected ? "selected" : ""}`}>
|
<div className={`wb-text-node ${isSelected ? "selected" : ""}`}>
|
||||||
{node.label || node.dbData?.label}
|
{node.label || node.dbData?.label || node.data?.label}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -29,19 +40,13 @@ export function WhiteboardView() {
|
||||||
style={{ borderColor: node.color || "#8b5cf6" }}
|
style={{ borderColor: node.color || "#8b5cf6" }}
|
||||||
>
|
>
|
||||||
<div className="wb-card-color" style={{ background: 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>
|
</div>
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!vaultPath || !name) return;
|
|
||||||
await saveCanvas(vaultPath, name, JSON.stringify({ savedAt: new Date().toISOString() })).catch(() => { });
|
|
||||||
}, [vaultPath, name]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JotaiProvider>
|
<CanvasProviderAny adapter={adapter} graphId={`whiteboard-${name}`}>
|
||||||
<CanvasStyleProvider isDark={true}>
|
|
||||||
<div className="whiteboard-wrapper">
|
<div className="whiteboard-wrapper">
|
||||||
<div className="whiteboard-toolbar">
|
<div className="whiteboard-toolbar">
|
||||||
<span className="whiteboard-title">📋 {name || "Untitled"}</span>
|
<span className="whiteboard-title">📋 {name || "Untitled"}</span>
|
||||||
|
|
@ -49,15 +54,10 @@ export function WhiteboardView() {
|
||||||
<button className="wb-btn wb-save" onClick={handleSave}>💾 Save</button>
|
<button className="wb-btn wb-save" onClick={handleSave}>💾 Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Canvas
|
<Canvas renderNode={renderNode} minZoom={0.1} maxZoom={5}>
|
||||||
renderNode={renderNode}
|
|
||||||
minZoom={0.1}
|
|
||||||
maxZoom={5}
|
|
||||||
>
|
|
||||||
<ViewportControls />
|
<ViewportControls />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
</CanvasStyleProvider>
|
</CanvasProviderAny>
|
||||||
</JotaiProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1084
src/index.css
1084
src/index.css
File diff suppressed because it is too large
Load diff
145
src/lib/clustering.ts
Normal file
145
src/lib/clustering.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -391,3 +391,175 @@ export async function getPinned(vaultPath: string): Promise<string[]> {
|
||||||
export async function setPinned(vaultPath: string, pinned: string[]): Promise<void> {
|
export async function setPinned(vaultPath: string, pinned: string[]): Promise<void> {
|
||||||
return invoke<void>("set_pinned", { vaultPath, pinned });
|
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 });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,11 @@ export default defineConfig(async () => ({
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ["**/src-tauri/**", "**/vault/**"],
|
ignored: ["**/src-tauri/**", "**/vault/**"],
|
||||||
},
|
},
|
||||||
|
fs: {
|
||||||
|
allow: [
|
||||||
|
".",
|
||||||
|
path.resolve(__dirname, "../blinksgg/gg-antifragile"),
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue