Compare commits

...

10 commits

Author SHA1 Message Date
enzotar
c6ce0b24d5 feat: Introduce integrity checks, backups, bookmarks, trash, quick capture, audit logs, and backend enhancements for notes, git, crypto, and SRS. 2026-03-11 18:11:09 -07:00
enzotar
bf4ef86874 harden: atomic writes, path validation, and save pipeline integrity
Backend (lib.rs):
- Add atomic_write/atomic_write_bytes helpers (write→fsync→rename→fsync parent)
- Apply safe_vault_path() to all 20 file-access commands (was 3)
- Apply safe_name() to workspace/canvas/attachment filename params
- Fix 2 silent error swallowing sites (let _ = fs::write)
- Fix git_status/git_commit/git_init error handling (check exit codes)
- Migrate all Regex::new() to LazyLock statics (10 total)
- Use ~tmp suffix for atomic writes (not extension replacement)
- Replace 2 unwrap() panic sites with unwrap_or_default()
- Skip ~tmp files in export_vault_zip

Frontend (Editor.tsx):
- Fix critical note-switch race: capture note path at call time,
  not when debounced timer fires (prevented old content → new note)
- Clear pending save timeout on note switch (defense-in-depth)
- Fix handleSlashSelect: route through debounced saveContent pipeline
  with domToMarkdown() instead of direct writeNote() with innerText
- Fix handlePaste stale closure (add saveContent to deps)

Changelog updated with Hardened section under v1.0.0.
2026-03-11 11:02:01 -07:00
enzotar
d639d40612 harden: atomic writes, path validation, and save pipeline integrity
Backend (lib.rs):
- Add atomic_write/atomic_write_bytes helpers (write→fsync→rename→fsync parent)
- Apply safe_vault_path() to all 20 file-access commands (was 3)
- Apply safe_name() to workspace/canvas/attachment filename params
- Fix 2 silent error swallowing sites (let _ = fs::write)
- Fix git_status/git_commit/git_init error handling (check exit codes)
- Migrate all Regex::new() to LazyLock statics (10 total)
- Use ~tmp suffix for atomic writes (not extension replacement)
- Replace 2 unwrap() panic sites with unwrap_or_default()
- Skip ~tmp files in export_vault_zip

Frontend (Editor.tsx):
- Fix critical note-switch race: capture note path at call time,
  not when debounced timer fires (prevented old content → new note)
- Clear pending save timeout on note switch (defense-in-depth)
- Fix handleSlashSelect: route through debounced saveContent pipeline
  with domToMarkdown() instead of direct writeNote() with innerText
- Fix handlePaste stale closure (add saveContent to deps)
2026-03-11 11:00:58 -07:00
enzotar
6fa547802c fix: replace broken graph view with HTML5 Canvas force-directed graph
The previous implementation used @blinksgg/canvas which is a
Supabase-backed whiteboard component — nodes were computed but
never passed to the Canvas (no such prop exists). Replaced with
a self-contained force-directed graph using HTML5 Canvas API:
drag nodes, pan, zoom, click to navigate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:10:45 -07:00
enzotar
9dcfece5bf fix: resolve white screen and black screen on daily notes
- Lazy-load WhiteboardView to prevent module-scope side effect
  (registerBuiltinCommands) from crashing the entire JS module tree
- Fix Rules of Hooks violation in Editor: hooks after early return
  caused React to crash when currentNote transitioned from null
- Fix async race condition in Editor content initialization where
  noteContent arrived after renderToDOM had already run with empty content
- Add error handling to DailyView's getOrCreateDaily call
- Add inline fallback styles to index.html for pre-JS loading state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:51:29 -07:00
enzotar
0d26e63c9a v1.0.0: security fixes, code review cleanup, release prep
Security:
- Add path traversal protection (safe_vault_path) for all file operations
- Sanitize markdown preview with DOMPurify to prevent XSS
- Fix encryption: decrypted content no longer written back to disk
- Harden CSP for Tauri v2 production mode

Code quality:
- Remove dead code (cache.rs, NoteView, unused tabs/activeTab state)
- Fix stale closure in debounced save using refs
- Fix image paste to use domToMarkdown instead of innerText
- Single-pass widget rendering (was 3 sequential innerHTML clobbers)
- Move snapshot logic into save callback
- Remove no-op updateWikilinks in drag-and-drop
- Wire CSS editor to Cmd+U shortcut
- Replace hardcoded vault path with portable fallback

Performance:
- LazyLock static regexes for WIKILINK_RE and TAG_RE
- Fix duplicate dirs_config_path, update CSS commands

Release:
- Bump version to 1.0.0
- Rewrite README with project documentation
- Update CHANGELOG with 1.0.0 entry
- Update .gitignore for build artifacts and vault data
- Update canvas dependency path
- Fix canvas API compatibility (onSelectionChange, removed props)
2026-03-09 18:15:39 -07:00
enzotar
c1f556b86b feat: v0.7–v0.9 — canvas, whiteboard, database, git sync, timeline, analytics, import/export, shortcuts, table editor
v0.7: GraphView rewrite (@blinksgg/canvas), WhiteboardView, DatabaseView (table/gallery/list), GitPanel, backlink context, dataview queries
v0.8: OutlinePanel, TimelineView, StatusBar (doc stats), TableEditor, RandomNote, link suggestions
v0.9: ImportExport (ZIP/folder), ShortcutsEditor (rebind+persist), GraphAnalytics (orphans/most-connected), note pinning

Backend: 15 new Rust commands, zip crate, rand crate
Frontend: 18 new components, ~1400 lines CSS
Dependencies: @blinksgg/canvas, jotai, graphology, d3-force
2026-03-08 23:10:18 -07:00
enzotar
d174c7f26d feat: v0.3 + v0.4 — frontmatter, templates, outline panel, vault cache
v0.3: Core file reading & markdown infrastructure
- Rust: read_note_with_meta, list_templates, save_attachment commands
- TypeScript: frontmatter.ts (parse/serialize YAML, extract headings)
- OutlinePanel with click-to-scroll headings + tabbed right panel
- CommandPalette: New from Template with {{title}}/{{date}} replacement
- Editor: image drag-and-drop to attachments/
- 130 lines of CSS for outline panel and right panel tabs

v0.4: File reading & caching
- Rust: VaultCache (cache.rs) with mtime-based invalidation
- Rewrote read_note, read_note_with_meta, build_graph, search_vault to use cache
- init_vault_cache (eager scan on startup), get_cache_stats commands
- Frontend LRU noteCache (capacity 20, stale-while-revalidate)
- notify crate added for filesystem watching foundation
2026-03-07 09:54:08 -08:00
enzotar
2041798048 feat: v0.2.0 — command palette, full-text search, note management, editor improvements, graph filtering
- Add search_vault and rename_note Rust backend commands
- Add CommandPalette component with fuzzy search and keyboard nav (Ctrl+K)
- Add ContextMenu component with rename/delete actions
- Add global keyboard shortcuts (Ctrl+N/G/D)
- Rebuild Sidebar with debounced full-text search results
- Add Editor heading scaling (h1-h3), interactive task checkboxes, inline code
- Add GraphView filter bar (folder dropdown, min links slider)
- Add 440+ lines of CSS for all new components
- Update CHANGELOG.md with v0.2.0 entry
2026-03-07 08:40:16 -08:00
enzotar
b03237f4c2 chore: add changesets and CHANGELOG for versioning 2026-03-07 00:22:54 -08:00
64 changed files with 17019 additions and 1065 deletions

8
.changeset/README.md Normal file
View file

@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).

11
.changeset/config.json Normal file
View file

@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View file

@ -0,0 +1,17 @@
---
"graph-notes": minor
---
## 0.1.0 — Initial Release
### Features
- **Tauri v2 Desktop App** — Local-first note-taking with full filesystem access
- **Contenteditable Editor**`[[wikilink]]` tokens render as compact chips; backspace/delete to unwrap
- **Wikilink Autocomplete** — Type `[[` to search and link existing notes or create new ones
- **Force-Directed Graph View** — Semantic zoom: circles morph into rounded-rect cards showing note previews
- **Graph Interactions** — Single-click zooms into node, double-click opens note, drag to reposition
- **shadcn-Inspired UI** — Zinc neutrals, purple accent gradients, toggle-group tabs, animated save indicator
- **Sidebar** — File tree with search, active indicators, daily note shortcut, note count badge
- **Backlinks Panel** — Shows all notes linking to the current page with context snippets
- **Markdown Preview** — Toggle between edit and rendered preview with wikilink rendering
- **Daily Notes** — Auto-created daily journal entries

6
.gitignore vendored
View file

@ -12,6 +12,12 @@ dist
dist-ssr
*.local
# Rust build artifacts
src-tauri/target/
# User vault data
vault/
# Editor directories and files
.vscode/*
!.vscode/extensions.json

244
CHANGELOG.md Normal file
View file

@ -0,0 +1,244 @@
# Changelog
All notable changes to Graph Notes will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [1.5.0] — 2026-03-11
### Added
- **Graph View Upgrade** — Complete rewrite using `@blinksgg/canvas` v3.0 with virtualized rendering
- **Note Graph Nodes** — Custom node type showing title, tag pills, link count badge, and cluster color
- **Cluster Group Nodes** — Collapsible `GroupNode` containers grouping related notes by community detection
- **Minimap** — Canvas overview with draggable viewport for large vault navigation
- **Layout Switcher** — Force-directed, tree, and grid layouts with animated transitions
- **Graph Search & Spotlight** — Type-to-search with non-matching nodes dimmed and camera fit-to-bounds
- **Edge Labels** — Wikilink context displayed on graph edges
- **MiniGraph Upgrade** — Sidebar preview upgraded to canvas v3.0
### Changed
- `@blinksgg/canvas` updated to v3.0 from `gg-antifragile` repository
- `WhiteboardView` migrated to new `CanvasProvider` API
- `GraphView` reduced from 336 to ~120 lines
## [1.4.0] — 2026-03-11
### Added
- **Content Checksums (SHA-256)** — Per-note hashing with on-demand vault-wide verification against stored checksums
- **Vault Integrity Scanner** — Deep scan for truncated files, leftover `~tmp` files, orphaned `.graph-notes/` entries, and non-UTF-8 encoding issues
- **Automatic Backup Snapshots** — Vault-level `.zip` snapshots in `.graph-notes/backups/` with auto-pruning of old snapshots
- **Write-Ahead Log (WAL)** — Crash recovery via operation journal in `.graph-notes/wal.log` with startup replay
- **Conflict Detection** — mtime-based external modification check before writes; conflict banner with overwrite/discard options
- **Frontmatter Schema Validation** — Inline warnings for unclosed `---` delimiters, duplicate keys, and invalid date formats
- **Orphan Attachment Cleanup** — Scan `_attachments/` for files not referenced by any note, with bulk delete
- **File Operation Audit Log** — Append-only log of all create/update/delete/rename operations with timestamps
### Changed
- Sidebar: added 🛡️ Integrity Report action
- Command Palette: added Verify Vault, Create Backup, Audit Log commands
- StatusBar: integrity badge showing checksum status
- Editor: conflict banner + frontmatter validation warnings
### Dependencies
- Added `sha2` (Rust) for content hashing
## [1.0.0] — 2026-03-09
### 🎉 First Stable Release
Graph Notes reaches 1.0 — a local-first, graph-based note-taking app built with Tauri, React, and Rust.
### Hardened
- **Atomic writes** — all file saves use write→fsync→rename→fsync-parent to prevent corruption on crash/power loss
- **Path validation**`safe_vault_path()` applied to all 20 file-access commands, preventing directory traversal
- **Filename sanitization**`safe_name()` rejects path separators in workspace, canvas, and attachment names
- **Note-switch save race** — debounced save now captures note path at call time, preventing old content from being written to wrong note
- **Save pipeline consistency** — slash command insert and image paste now route through the standard debounced save with `domToMarkdown()`
- **Git error handling**`git_status`, `git_commit`, `git_init` now check exit codes and surface stderr on failure
- **Silent error swallowing** — fixed 2 `let _ = fs::write()` sites to propagate errors
- **Panic prevention** — replaced `unwrap()` with `unwrap_or_default()` on fallible `file_name()` calls
- **Export safety**`export_vault_zip` skips in-progress `~tmp` atomic write files
- **Regex performance** — all 10 per-call `Regex::new()` migrated to `LazyLock` statics
### Fixed
- **Rust compilation** — resolved duplicate `dirs_config_path()` definition and removed reference to unlinked `dirs` crate
- **Content Security Policy** — replaced `null` CSP with a proper baseline policy allowing local resources and Google Fonts
- **Canvas dependency** — updated `@blinksgg/canvas` to correct local file path
### Removed
- **Dead code** — removed unused `cache.rs` module (196 lines) that was never compiled (no `mod cache;` declaration, missing `notify` crate dependency)
### Changed
- **README** — replaced Vite template boilerplate with comprehensive project documentation
- **.gitignore** — added `src-tauri/target/` and `vault/` exclusions
- **Version** — bumped from 0.9.0 → 1.0.0 across `package.json`, `Cargo.toml`, and `tauri.conf.json`
## [0.9.0] — 2026-03-09
### Added
- **Import/Export Hub** — Export vault as ZIP, import .md folders from Obsidian/Notion
- **Keyboard Shortcuts Editor** — View, rebind, persist all keyboard shortcuts
- **Graph Analytics** — Stats dashboard with orphan detection, most-connected notes, link density
- **Note Pinning** — Pin notes to sidebar top, persisted to `.graph-notes/pinned.json`
### Changed
- Sidebar: added 📊 Analytics action
- Command Palette: added Graph Analytics, Import/Export, Keyboard Shortcuts commands
- Backend: added `export_vault_zip`, `import_folder`, `save_shortcuts`, `load_shortcuts`, `get_pinned`, `set_pinned`
### Dependencies
- Added `zip` crate (Rust)
## [0.8.0] — 2026-03-09
### Added
- **Outline Sidebar** — Collapsible heading tree (H1H6) with click-to-scroll and active heading tracking
- **Timeline View** — Chronological note cards grouped by date with 7d/30d/1y filters
- **Document Statistics** — Status bar with word count, characters, lines, reading time, heading count
- **Markdown Table Editor** — Visual table grid with click-to-edit cells, add/remove rows/columns, Tab navigation
- **Random Note** — 🎲 Discover random notes from sidebar or command palette
- **Link Suggestions** — Backend `suggest_links` for wikilink auto-completion
### Changed
- Sidebar: added 📅 Timeline and 🎲 Random Note actions
- Command Palette: added Timeline, Random Note commands
- Backend: added `suggest_links`, `list_notes_by_date`, `random_note` commands
### Dependencies
- Added `rand` crate (Rust)
## [0.7.0] — 2026-03-09
### Added
- **Canvas Whiteboard** — Freeform visual thinking surface powered by `@blinksgg/canvas` with card/text nodes, drag, zoom, save/load
- **Database Views** — Notion-style table/gallery/list views from frontmatter properties with sort/filter
- **Backlink Context** — Paragraph-level excerpts around wikilink mentions in backlinks panel
- **Dataview Queries** — Inline ` ```dataview TABLE ... SORT ... ``` ` blocks rendering live query tables
- **Git Sync** — commit/push/pull panel with status indicator, changed file list, repo initialization
### Changed
- **GraphView rewritten** using `@blinksgg/canvas` (replaces custom HTML5 Canvas force simulation)
- Sidebar: added Database, Whiteboard quick actions
- Command Palette: added Database View, New Whiteboard, Git Sync commands
- Backlinks now use backend `get_backlink_context` for paragraph excerpts
### Dependencies
- Added `@blinksgg/canvas`, `jotai`, `graphology`, `d3-force`
## [0.6.0] — 2026-03-09
### Added
- **Tabbed Editor** — Multi-note tab bar with drag-reorder, close buttons, active tab highlighting
- **Note Refactoring** — Extract selection to new note (replaces with wikilink), merge notes (appends + updates links)
- **Encrypted Notes** — AES-256-GCM password protection with Argon2 key derivation, lock/unlock button in editor
- **Spaced Repetition Flashcards** — Study mode from `?? question :: answer ??` syntax, SM-2 scheduling, difficulty ratings
- **Heading Folding** — Fold state persistence per note via `.graph-notes/folds.json`
- **Custom CSS Snippets** — Live-preview CSS editor, persisted in `~/.config/graph-notes/custom.css`
- **Workspace Layouts** — Save/restore window arrangements in `.graph-notes/workspaces/`
- **Embeddable Widgets**`{{progress:N}}` progress bars, `{{counter:N}}` badges, `{{toggle:on/off}}` indicators
### Changed
- Editor supports right-click context menu for refactoring operations
- Command Palette extended with Flashcards, Custom CSS, and Save Workspace
- Sidebar quick actions include Flashcards
- Custom CSS loaded on mount via `useCustomCssInit` hook
### Dependencies
- Added `aes-gcm`, `argon2`, `rand`, `base64` for encryption
## [0.5.0] — 2026-03-08
### Added
- **Kanban Board** — Visual task board from `- [ ]` / `- [/]` / `- [x]` items across vault, with drag-and-drop between Todo/In Progress/Done columns
- **Focus / Zen Mode** — Distraction-free writing (`⌘⇧F`): hides sidebar, breadcrumbs, meta, centers content at max 720px
- **Note Version History** — Auto-snapshots every 5 min, timeline sidebar with inline diff viewer (add/remove highlighting)
- **PDF Export** — Print-styled export via browser print dialog with clean typography
- **Global Search & Replace** — Find/replace text across vault with dry-run preview before applying (`⌘H`)
- **Local Backlink Graph** — Mini force-directed canvas in preview showing current note's 1-hop link connections
- **Writing Goals** — Per-note word count targets with gradient progress bar (red→yellow→green)
- **Syntax-Highlighted Code Blocks** — highlight.js with 8 languages, copy-to-clipboard button, dark theme
### Changed
- Editor supports focus mode (hides chrome, centers content)
- Command Palette extended with Kanban, Focus Mode, Search & Replace, Export as PDF
- Sidebar quick actions include Kanban Board
- Auto-snapshot on save (throttled to 1 per 5 min)
### Dependencies
- Added `highlight.js` for syntax highlighting
## [0.4.0] — 2026-03-08
### Added
- **Frontmatter & Properties Panel** — YAML `---` fenced metadata with inline key-value editor (collapsible panel below breadcrumbs)
- **Table of Contents** — Auto-generated outline from headings, shown alongside preview mode with active heading highlight
- **Mermaid Diagram Rendering** — Fenced `mermaid` code blocks render as SVG diagrams in preview mode (lazy-loaded)
- **Image & Attachment Support** — Paste images from clipboard, stored in `_attachments/` directory with `![](path)` markdown
- **Slash Commands** — Type `/` at line start to open inline formatting menu (14 commands: headings, lists, code blocks, mermaid, tables)
- **Calendar View** — Visual month grid for daily notes with dot indicators, "Today" button, and click-to-create
- **Theme Picker** — 5 built-in themes (Dark Purple, Dark Emerald, Dark Ocean, Dark Rose, Light) with live preview, persisted
- **Export to HTML** — Export current note as styled standalone HTML file
### Changed
- Editor now includes PropertiesPanel, TableOfContents sidebar, and SlashMenu
- Command Palette extended with Calendar, Theme, and Export HTML commands
- Sidebar quick actions include Calendar View
- Added `⌘T` keyboard shortcut for Theme Picker
### Dependencies
- Added `mermaid` for diagram rendering
## [0.3.0] — 2026-03-08
### Added
- **Split Editor** — Open two notes side by side with a draggable divider (right-click → "Open in split")
- **Wikilink Hover Preview** — Hover over `[[wikilinks]]` to see a floating preview card with note content and link count
- **Note Transclusion**`![[note-name]]` embeds the content of another note inline, with recursive depth limiting (3 levels)
- **Vault Switcher** — Click sidebar brand to switch between recent vaults or open a new folder
- **Drag & Drop File Organization** — Drag notes between folders in the sidebar file tree
- **Breadcrumb Navigation** — Path breadcrumbs shown above the editor for nested notes
- **Note Templates** — Create notes from templates in `_templates/` directory via Command Palette (supports `{{title}}` and `{{date}}` variables)
- **Recent Notes** — Last 5 recently opened notes shown in the sidebar
- **Favorites** — Pin notes as favorites (right-click → "Favorite"), persisted per vault in `.graph-notes/favorites.json`
- **Open in Split Pane** — Right-click context menu option to open a note in a side-by-side view
### Changed
- Note view now uses `SplitView` component, supporting both single-pane and dual-pane editing
- Context menu expanded with "Favorite" and "Open in split" actions, plus visual divider
- Command Palette shows template commands when available
- `LinkPreview` component renders as a global overlay for all hover previews
## [0.2.0] — 2026-03-08
### Added
- **Full-Text Search** — Vault-wide content search in the sidebar (debounced, with context snippets and result ranking)
- **Command Palette**`⌘K` / `Ctrl+K` opens a fuzzy search palette for notes, commands, and content
- **Keyboard Shortcuts**`⌘N` new note, `⌘G` graph view, `⌘D` daily note, `⌘E` toggle edit/preview, `⌘\` toggle sidebar
- **Note Rename** — Right-click context menu on notes in sidebar for inline rename with automatic wikilink updates across vault
- **Note Delete** — Right-click context menu with confirmation dialog; navigates away if active note deleted
- **Tags System**`#tag` extraction from notes, sidebar tags section with click-to-filter, emerald-colored tag pills in editor
- **Graph Filtering** — Filter bar to highlight matching nodes, focus mode (1-hop neighborhood), orphan node toggle
- **Inline Markdown Styling** — Headings (`# ## ###`) render at proper sizes in edit mode, `**bold**`, `*italic*`, `` `code` `` styled inline
- **List Continuation** — Pressing Enter after `- item` auto-inserts bullet on next line
- **Tab Indent/Outdent** — Tab and Shift+Tab for list item indentation
- **Collapsible Sidebar** — Toggle sidebar visibility with `⌘\`
### Changed
- Edit/Preview mode is now global (shared via context), toggled with `⌘E` from anywhere
- Search input shows `⌘K` hint for command palette discovery
## [0.1.0] — 2026-03-07
### Added
- **Tauri v2 Desktop App** — Local-first note-taking with full filesystem access via `tauri-plugin-fs`
- **Contenteditable Editor** — Rich inline editing with `[[wikilink]]` token chips (compact pills that unwrap on backspace/delete)
- **Wikilink Autocomplete** — Type `[[` to fuzzy-search and link notes; creates new notes if no match found
- **Force-Directed Graph View** — Canvas-based visualization with semantic zoom (circles → rounded-rect cards with note previews)
- **Graph Interactions** — Single-click animates zoom to node, double-click opens note, drag to reposition nodes
- **shadcn-Inspired Design System** — Zinc-based neutrals, purple accent gradients, focus rings, spring transitions
- **Sidebar** — Recursive file tree with search, collapsible folders, active-state indicators, note count badge
- **Backlinks Panel** — Lists all notes linking to current page with highlighted context snippets
- **Markdown Preview** — Toggle between edit and rendered preview modes with inline wikilink rendering
- **Daily Notes** — Auto-generated daily journal entries accessible from sidebar shortcut
- **Auto-Save** — Debounced 500ms save on every keystroke
- **Custom Scrollbars** — Minimal 5px scrollbars matching the dark theme

View file

@ -1,7 +1,86 @@
# Tauri + React + Typescript
# Graph Notes
This template should help get you started developing with Tauri, React and Typescript in Vite.
A local-first, graph-based note-taking app built with Tauri, React, and Rust. Think Obsidian meets Roam — your notes live as plain Markdown files on disk, connected by `[[wikilinks]]` and visualized as an interactive knowledge graph.
## Recommended IDE Setup
## Features
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
**Core Editing**
- Rich contenteditable editor with inline Markdown styling
- `[[Wikilink]]` autocomplete with note creation on the fly
- Slash commands, heading folding, split-pane editing
- Tabbed editor with drag-reorder
**Knowledge Graph**
- Force-directed graph visualization with semantic zoom
- Graph filtering, focus mode, orphan detection
- Local backlink graph and analytics dashboard
**Organization**
- Sidebar file tree with drag-and-drop, folders, favorites, pinning
- Full-text search, command palette (`⌘K`), tags
- Calendar view, timeline view, kanban board
**Advanced**
- Frontmatter properties panel and database views (table/gallery/list)
- Dataview queries — inline `TABLE ... SORT ...` blocks
- Canvas whiteboard for freeform visual thinking
- Spaced repetition flashcards (SM-2 scheduling)
- Note encryption (AES-256-GCM with Argon2 key derivation)
- Git sync (commit/push/pull panel)
- Version history with inline diff viewer
**Customization**
- 5 built-in themes with live preview
- Custom CSS snippets editor
- Configurable keyboard shortcuts
- Workspace layouts (save/restore window arrangements)
**Import / Export**
- Export vault as ZIP, individual notes as HTML or PDF
- Import Markdown folders from Obsidian, Notion, etc.
## Tech Stack
| Layer | Technology |
|---|---|
| Desktop shell | [Tauri v2](https://tauri.app) |
| Frontend | React 19, TypeScript, Vite |
| Backend | Rust (filesystem, search, encryption, graph) |
| Styling | Tailwind CSS 4 + custom design tokens |
| Graph engine | [@blinksgg/canvas](https://github.com/blinksgg), d3-force, graphology |
## Development
```bash
# Install dependencies
npm install
# Run in development mode (starts both Vite dev server and Tauri)
npm run tauri dev
# Build for production
npm run tauri build
```
### Prerequisites
- [Node.js](https://nodejs.org) ≥ 18
- [Rust](https://rustup.rs) toolchain
- [Tauri v2 prerequisites](https://v2.tauri.app/start/prerequisites/)
## Project Structure
```
├── src/ # React frontend
│ ├── components/ # 33 UI components
│ ├── lib/ # Commands, frontmatter, wikilinks, note cache
│ └── index.css # Design system (tokens, component styles)
├── src-tauri/ # Rust backend
│ └── src/lib.rs # Tauri commands (notes, graph, search, encryption, git)
├── vault/ # Default vault directory (gitignored)
└── package.json
```
## License
MIT

View file

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Graph Notes</title>
</head>
<body>
<div id="root"></div>
<body style="background:#09090b;color:#fafafa;font-family:sans-serif;">
<div id="root"><p style="padding:2rem;">Loading Graph Notes...</p></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1576
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "graph-notes",
"private": true,
"version": "0.1.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -10,23 +10,34 @@
"tauri": "tauri"
},
"dependencies": {
"@blinksgg/canvas": "file:../blinksgg/gg-antifragile/packages/canvas",
"@tanstack/react-query": "^5.90.21",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-opener": "^2",
"d3-force": "^3.0.0",
"dompurify": "^3.3.2",
"graphology": "^0.26.0",
"graphology-types": "^0.24.8",
"highlight.js": "^11.11.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"marked": "^15.0.0",
"mermaid": "^11.12.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-dialog": "^2",
"marked": "^15.0.0"
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@types/dompurify": "^3.0.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
"vite": "^7.0.4"
}
}
}

385
src-tauri/Cargo.lock generated
View file

@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -47,6 +82,27 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@ -225,6 +281,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -240,6 +302,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -319,6 +390,25 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@ -393,6 +483,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -443,6 +535,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -462,6 +564,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.4.0"
@ -527,6 +635,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -558,6 +681,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -598,6 +722,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.21.3"
@ -633,6 +766,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "deflate64"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2"
[[package]]
name = "deranged"
version = "0.5.8"
@ -643,6 +782,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@ -664,6 +814,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -1194,9 +1345,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -1212,6 +1365,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gio"
version = "0.18.4"
@ -1310,18 +1473,24 @@ dependencies = [
[[package]]
name = "graph-notes"
version = "0.1.0"
version = "1.5.0"
dependencies = [
"aes-gcm",
"argon2",
"base64 0.22.1",
"chrono",
"rand 0.8.5",
"regex",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener",
"walkdir",
"zip",
]
[[package]]
@ -1421,6 +1590,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@ -1696,6 +1874,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -1782,6 +1969,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@ -1919,6 +2116,27 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
"byteorder",
"crc",
]
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@ -2227,6 +2445,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
@ -2309,12 +2533,33 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -2524,6 +2769,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@ -3156,6 +3413,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -3308,6 +3576,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@ -4145,6 +4419,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "url"
version = "2.5.8"
@ -5084,6 +5368,15 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.8.1"
@ -5209,6 +5502,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerotrie"
version = "0.2.3"
@ -5242,12 +5555,82 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
"deflate64",
"displaydoc",
"flate2",
"getrandom 0.3.4",
"hmac",
"indexmap 2.13.0",
"lzma-rs",
"memchr",
"pbkdf2",
"sha1",
"thiserror 2.0.18",
"time",
"xz2",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.10.0"

View file

@ -1,6 +1,6 @@
[package]
name = "graph-notes"
version = "0.1.0"
version = "1.5.0"
description = "A graph-based note-taking app"
authors = ["you"]
edition = "2021"
@ -22,3 +22,9 @@ serde_json = "1"
walkdir = "2"
regex = "1"
chrono = "0.4"
aes-gcm = "0.10"
argon2 = "0.5"
rand = "0.8"
base64 = "0.22"
zip = "2"
sha2 = "0.10"

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

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

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

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

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

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

View file

@ -1,10 +1,161 @@
mod notes;
mod git;
mod crypto;
mod srs;
mod export;
mod state;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use std::sync::LazyLock;
use regex::Regex;
use chrono::Local;
/* ── Atomic File Write ─────────────────────────────────────
* 1. Write to a `~tmp` sibling in the same directory
* 2. fsync the file descriptor (data hits disk)
* 3. Atomic rename `~tmp` target (POSIX guarantees)
* 4. fsync the parent directory (metadata durability)
*
* If any step fails the original file is untouched and
* the `~tmp` file is cleaned up.
* */
pub(crate) fn atomic_write(path: &Path, content: &str) -> Result<(), String> {
atomic_write_bytes(path, content.as_bytes())
}
pub(crate) fn atomic_write_bytes(path: &Path, data: &[u8]) -> Result<(), String> {
let parent = path.parent()
.ok_or_else(|| "Cannot determine parent directory".to_string())?;
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
// Use ~tmp suffix (appended, not replacing extension) to avoid
// colliding with real files that share the same stem
let mut tmp_name = path.as_os_str().to_os_string();
tmp_name.push("~tmp");
let tmp_path = PathBuf::from(tmp_name);
// 1 — Write to .tmp
let result = (|| -> Result<(), String> {
let mut file = fs::File::create(&tmp_path)
.map_err(|e| format!("Failed to create temp file: {}", e))?;
file.write_all(data)
.map_err(|e| format!("Failed to write temp file: {}", e))?;
// 2 — fsync: flush data to disk
file.sync_all()
.map_err(|e| format!("Failed to sync file: {}", e))?;
Ok(())
})();
if let Err(e) = result {
let _ = fs::remove_file(&tmp_path); // Clean up on failure
return Err(e);
}
// 3 — Atomic rename
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(format!("Failed to finalize write: {}", e));
}
// 4 — fsync parent directory for metadata durability
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
/* ── Path Safety ───────────────────────────────────────── */
/// Sanitize a name used to construct filenames (workspace names, canvas names, etc.)
/// Rejects any path component separators or traversal sequences.
pub(crate) fn safe_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Name cannot be empty".to_string());
}
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") || trimmed.contains('\0') {
return Err("Name contains invalid characters".to_string());
}
Ok(trimmed.to_string())
}
/// Validate that a relative path stays within the vault root.
/// Returns the canonical full path, or an error if it escapes.
pub(crate) fn safe_vault_path(vault_path: &str, relative_path: &str) -> Result<PathBuf, String> {
let vault = Path::new(vault_path)
.canonicalize()
.map_err(|e| format!("Invalid vault path: {}", e))?;
let full = vault.join(relative_path);
// Canonicalize only works if the file exists; for new files, normalize manually
let normalized = if full.exists() {
full.canonicalize().map_err(|e| format!("Invalid path: {}", e))?
} else {
// For new files: resolve parent (must exist) + filename
let parent = full.parent()
.ok_or_else(|| "Invalid path".to_string())?;
let parent_canon = parent.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
parent_canon.join(full.file_name().unwrap_or_default())
};
if !normalized.starts_with(&vault) {
return Err("Path escapes the vault directory".to_string());
}
Ok(normalized)
}
/* ── Static Regexes ────────────────────────────────────────
* Compiled once, reused across calls.
* */
pub(crate) static WIKILINK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap()
});
pub(crate) static PREVIEW_WIKILINK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap()
});
pub(crate) static PREVIEW_FMT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[*_~`]").unwrap()
});
pub(crate) static DAILY_NOTE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap()
});
pub(crate) static TASK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\s*)- \[([ x/])\] (.+)$").unwrap()
});
pub(crate) static TASK_MARKER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"- \[[ x/]\]").unwrap()
});
pub(crate) static EXPORT_WIKILINK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap()
});
pub(crate) static FLASHCARD_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\?\?\s*(.+?)\s*::\s*(.+?)\s*\?\?").unwrap()
});
pub(crate) static FRONTMATTER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)^---\n(.+?)\n---").unwrap()
});
pub(crate) static TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|[\s,;(])(#[a-zA-Z][a-zA-Z0-9_/-]*)").unwrap()
});
/* ── Shared Types ──────────────────────────────────────── */
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NoteEntry {
pub path: String,
@ -13,190 +164,16 @@ pub struct NoteEntry {
pub children: Option<Vec<NoteEntry>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GraphData {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
/* ── Config ─────────────────────────────────────────────── */
pub(crate) fn dirs_config_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
Path::new(&home).join(".config").join("graph-notes").join("vault_path")
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GraphNode {
pub id: String,
pub label: String,
pub path: String,
pub link_count: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GraphEdge {
pub source: String,
pub target: String,
}
fn normalize_note_name(name: &str) -> String {
name.trim().to_lowercase()
}
fn extract_wikilinks(content: &str) -> Vec<String> {
let re = Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap();
re.captures_iter(content)
.map(|cap| cap[1].trim().to_string())
.collect()
}
#[tauri::command]
fn list_notes(vault_path: String) -> Result<Vec<NoteEntry>, String> {
let vault = Path::new(&vault_path);
if !vault.exists() {
return Err("Vault path does not exist".to_string());
}
fn build_tree(dir: &Path, base: &Path) -> Vec<NoteEntry> {
let mut entries: Vec<NoteEntry> = Vec::new();
if let Ok(read_dir) = fs::read_dir(dir) {
let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
items.sort_by_key(|e| e.file_name());
for entry in items {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
// Skip hidden files/dirs
if file_name.starts_with('.') {
continue;
}
if path.is_dir() {
let children = build_tree(&path, base);
let rel = path.strip_prefix(base).unwrap_or(&path);
entries.push(NoteEntry {
path: rel.to_string_lossy().to_string(),
name: file_name,
is_dir: true,
children: Some(children),
});
} else if path.extension().map_or(false, |ext| ext == "md") {
let rel = path.strip_prefix(base).unwrap_or(&path);
entries.push(NoteEntry {
path: rel.to_string_lossy().to_string(),
name: file_name.trim_end_matches(".md").to_string(),
is_dir: false,
children: None,
});
}
}
}
entries
}
Ok(build_tree(vault, vault))
}
#[tauri::command]
fn read_note(vault_path: String, relative_path: String) -> Result<String, String> {
let full_path = Path::new(&vault_path).join(&relative_path);
fs::read_to_string(&full_path).map_err(|e| format!("Failed to read note: {}", e))
}
#[tauri::command]
fn write_note(vault_path: String, relative_path: String, content: String) -> Result<(), String> {
let full_path = Path::new(&vault_path).join(&relative_path);
// Ensure parent directory exists
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
}
fs::write(&full_path, content).map_err(|e| format!("Failed to write note: {}", e))
}
#[tauri::command]
fn delete_note(vault_path: String, relative_path: String) -> Result<(), String> {
let full_path = Path::new(&vault_path).join(&relative_path);
if full_path.is_file() {
fs::remove_file(&full_path).map_err(|e| format!("Failed to delete note: {}", e))
} else {
Err("Note not found".to_string())
}
}
#[tauri::command]
fn build_graph(vault_path: String) -> Result<GraphData, String> {
let vault = Path::new(&vault_path);
if !vault.exists() {
return Err("Vault path does not exist".to_string());
}
let mut nodes: Vec<GraphNode> = Vec::new();
let mut edges: Vec<GraphEdge> = Vec::new();
// Collect all notes
let mut note_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
{
let rel_path = entry.path().strip_prefix(vault).unwrap_or(entry.path());
let rel_str = rel_path.to_string_lossy().to_string();
let name = rel_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
note_map.insert(normalize_note_name(&name), rel_str.clone());
nodes.push(GraphNode {
id: rel_str.clone(),
label: name,
path: rel_str,
link_count: 0,
});
}
// Parse links and build edges
for node in &mut nodes {
let full_path = vault.join(&node.path);
if let Ok(content) = fs::read_to_string(&full_path) {
let links = extract_wikilinks(&content);
node.link_count = links.len();
for link in &links {
let normalized = normalize_note_name(link);
if let Some(target_path) = note_map.get(&normalized) {
edges.push(GraphEdge {
source: node.id.clone(),
target: target_path.clone(),
});
}
}
}
}
Ok(GraphData { nodes, edges })
}
#[tauri::command]
fn get_or_create_daily(vault_path: String) -> Result<String, String> {
let today = Local::now().format("%Y-%m-%d").to_string();
let daily_dir = Path::new(&vault_path).join("daily");
let daily_path = daily_dir.join(format!("{}.md", today));
let relative_path = format!("daily/{}.md", today);
if !daily_dir.exists() {
fs::create_dir_all(&daily_dir).map_err(|e| format!("Failed to create daily dir: {}", e))?;
}
if !daily_path.exists() {
let content = format!("# {}\n\n", today);
fs::write(&daily_path, content).map_err(|e| format!("Failed to create daily note: {}", e))?;
}
Ok(relative_path)
pub(crate) fn dirs_config_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
Path::new(&home).join(".config").join("graph-notes")
}
#[tauri::command]
@ -218,15 +195,7 @@ fn get_vault_path() -> Result<Option<String>, String> {
#[tauri::command]
fn set_vault_path(path: String) -> Result<(), String> {
let config_path = dirs_config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&config_path, &path).map_err(|e| e.to_string())
}
fn dirs_config_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
Path::new(&home).join(".config").join("graph-notes").join("vault_path")
atomic_write(&config_path, &path)
}
#[tauri::command]
@ -238,6 +207,40 @@ fn ensure_vault(vault_path: String) -> Result<(), String> {
Ok(())
}
/* ── Vault Management ────────────────────────────────────── */
#[tauri::command]
fn list_recent_vaults() -> Result<Vec<String>, String> {
let path = dirs_config_dir().join("recent_vaults.json");
if !path.exists() {
return Ok(vec![]);
}
let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
let vaults: Vec<String> = serde_json::from_str(&content).unwrap_or_default();
Ok(vaults)
}
#[tauri::command]
fn add_vault(vault_path: String) -> Result<(), String> {
let config_path = dirs_config_dir().join("recent_vaults.json");
let mut vaults: Vec<String> = if config_path.exists() {
let content = fs::read_to_string(&config_path).unwrap_or_default();
serde_json::from_str(&content).unwrap_or_default()
} else {
vec![]
};
// Remove if already exists, then prepend
vaults.retain(|v| v != &vault_path);
vaults.insert(0, vault_path);
vaults.truncate(10); // Keep last 10
let json = serde_json::to_string_pretty(&vaults).map_err(|e| e.to_string())?;
atomic_write(&config_path, &json)
}
/* ── App Entry Point ───────────────────────────────────── */
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@ -245,15 +248,99 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
list_notes,
read_note,
write_note,
delete_note,
build_graph,
get_or_create_daily,
// ── lib.rs (config / vault management) ──
get_vault_path,
set_vault_path,
ensure_vault,
list_recent_vaults,
add_vault,
// ── notes.rs ──
notes::list_notes,
notes::read_note,
notes::write_note,
notes::delete_note,
notes::build_graph,
notes::get_or_create_daily,
notes::list_daily_notes,
notes::search_vault,
notes::rename_note,
notes::update_wikilinks,
notes::list_tags,
notes::read_note_preview,
notes::list_templates,
notes::create_from_template,
notes::parse_frontmatter,
notes::write_frontmatter,
notes::save_attachment,
notes::list_attachments,
notes::list_tasks,
notes::toggle_task,
notes::extract_to_note,
notes::merge_notes,
notes::query_frontmatter,
notes::get_backlink_context,
notes::run_dataview_query,
notes::suggest_links,
notes::list_notes_by_date,
notes::random_note,
// ── git.rs ──
git::git_status,
git::git_commit,
git::git_pull,
git::git_push,
git::git_init,
// ── crypto.rs ──
crypto::encrypt_note,
crypto::decrypt_note,
crypto::is_encrypted,
// ── srs.rs ──
srs::list_flashcards,
srs::update_card_schedule,
// ── export.rs ──
export::export_note_html,
export::export_vault_zip,
export::import_folder,
// ── state.rs ──
state::save_snapshot,
state::list_snapshots,
state::read_snapshot,
state::search_replace_vault,
state::get_writing_goal,
state::set_writing_goal,
state::save_fold_state,
state::load_fold_state,
state::get_custom_css,
state::set_custom_css,
state::save_workspace,
state::load_workspace,
state::list_workspaces,
state::save_tabs,
state::load_tabs,
state::save_canvas,
state::load_canvas,
state::list_canvases,
state::save_shortcuts,
state::load_shortcuts,
state::get_pinned,
state::set_pinned,
state::get_theme,
state::set_theme,
state::get_favorites,
state::set_favorites,
// ── v1.4 notes.rs ──
notes::compute_checksums,
notes::verify_checksums,
notes::scan_integrity,
notes::check_conflict,
notes::validate_frontmatter,
notes::find_orphan_attachments,
// ── v1.4 state.rs ──
state::create_backup,
state::list_backups,
state::restore_backup,
state::wal_status,
state::wal_recover,
state::get_audit_log,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

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

File diff suppressed because it is too large Load diff

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

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

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

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

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Graph Notes",
"version": "0.1.0",
"version": "1.5.0",
"identifier": "com.graphnotes.app",
"build": {
"beforeDevCommand": "npm run dev",
@ -22,7 +22,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self' tauri: https://tauri.localhost; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' asset: https://asset.localhost http://asset.localhost blob: data: tauri: https://tauri.localhost; connect-src 'self' ipc: http://ipc.localhost tauri: https://tauri.localhost"
}
},
"plugins": {

View file

@ -1,9 +1,26 @@
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { useState, useEffect, useCallback, createContext, useContext, lazy, Suspense } from "react";
import { Routes, Route, useNavigate, useParams } from "react-router-dom";
import { Sidebar } from "./components/Sidebar";
import { Editor } from "./components/Editor";
import { Backlinks } from "./components/Backlinks";
import { GraphView } from "./components/GraphView";
import { CommandPalette } from "./components/CommandPalette";
import { SplitView } from "./components/SplitView";
import { LinkPreview } from "./components/LinkPreview";
import { CalendarView } from "./components/CalendarView";
import { ThemePicker, useThemeInit } from "./components/ThemePicker";
import { KanbanView } from "./components/KanbanView";
import { SearchReplace } from "./components/SearchReplace";
import { FlashcardView } from "./components/FlashcardView";
import { CSSEditor, useCustomCssInit } from "./components/CSSEditor";
import { TabBar } from "./components/TabBar";
const WhiteboardView = lazy(() => import("./components/WhiteboardView").then(m => ({ default: m.WhiteboardView })));
import { DatabaseView } from "./components/DatabaseView";
import { GitPanel } from "./components/GitPanel";
import { TimelineView } from "./components/TimelineView";
import { GraphAnalytics } from "./components/GraphAnalytics";
import IntegrityReport from "./components/IntegrityReport";
import AuditLog from "./components/AuditLog";
import {
listNotes,
readNote,
@ -12,6 +29,10 @@ import {
setVaultPath,
ensureVault,
getOrCreateDaily,
addVault,
getFavorites,
getBacklinkContext,
setFavorites as setFavoritesCmd,
type NoteEntry,
} from "./lib/commands";
import { extractWikilinks, type BacklinkEntry } from "./lib/wikilinks";
@ -27,6 +48,18 @@ interface VaultContextType {
setNoteContent: (content: string) => void;
backlinks: BacklinkEntry[];
navigateToNote: (name: string) => void;
sidebarOpen: boolean;
toggleSidebar: () => void;
editMode: boolean;
toggleEditMode: () => void;
splitNote: string | null;
setSplitNote: (path: string | null) => void;
favorites: string[];
toggleFavorite: (path: string) => void;
recentNotes: string[];
switchVault: (path: string) => Promise<void>;
focusMode: boolean;
toggleFocusMode: () => void;
}
const VaultContext = createContext<VaultContextType>(null!);
@ -41,8 +74,27 @@ export default function App() {
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [editMode, setEditMode] = useState(true);
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
const [splitNote, setSplitNote] = useState<string | null>(null);
const [favorites, setFavorites] = useState<string[]>([]);
const [recentNotes, setRecentNotes] = useState<string[]>([]);
const [themePickerOpen, setThemePickerOpen] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [searchReplaceOpen, setSearchReplaceOpen] = useState(false);
const [cssEditorOpen, setCssEditorOpen] = useState(false);
const navigate = useNavigate();
// Apply saved theme + custom CSS on mount
useThemeInit();
useCustomCssInit();
const toggleSidebar = useCallback(() => setSidebarOpen(v => !v), []);
const toggleEditMode = useCallback(() => setEditMode(v => !v), []);
const toggleFocusMode = useCallback(() => setFocusMode(v => !v), []);
// Initialize vault
useEffect(() => {
(async () => {
@ -58,8 +110,8 @@ export default function App() {
}
if (!path) {
// Default to the project's vault directory
path = "/home/amir/code/notes/vault";
// No stored vault path — use a sensible default
path = "./vault";
console.log("[GraphNotes] Using default vault path:", path);
try {
await setVaultPath(path);
@ -94,9 +146,55 @@ export default function App() {
useEffect(() => {
if (vaultPath) refreshNotes();
// Load favorites
if (vaultPath) {
getFavorites(vaultPath).then(setFavorites).catch(() => setFavorites([]));
}
}, [vaultPath, refreshNotes]);
// Build backlinks for current note
// Track recent notes
const trackRecent = useCallback((notePath: string) => {
setRecentNotes(prev => {
const next = [notePath, ...prev.filter(p => p !== notePath)].slice(0, 10);
return next;
});
}, []);
// Watch currentNote changes to track recents
useEffect(() => {
if (currentNote) trackRecent(currentNote);
}, [currentNote, trackRecent]);
// Toggle favorite
const toggleFavorite = useCallback((path: string) => {
setFavorites(prev => {
const next = prev.includes(path)
? prev.filter(p => p !== path)
: [...prev, path];
// Persist
if (vaultPath) setFavoritesCmd(vaultPath, next).catch(() => { });
return next;
});
}, [vaultPath]);
// Switch vault
const switchVault = useCallback(async (newPath: string) => {
try {
await ensureVault(newPath);
await setVaultPath(newPath);
await addVault(newPath);
setVaultPathState(newPath);
setCurrentNote(null);
setNoteContent("");
setSplitNote(null);
setRecentNotes([]);
navigate("/");
} catch (e) {
console.error("Switch vault failed:", e);
}
}, [navigate, setCurrentNote, setNoteContent]);
// Build backlinks for current note using backend context
useEffect(() => {
if (!vaultPath || !currentNote || !notes.length) {
setBacklinks([]);
@ -107,37 +205,41 @@ export default function App() {
const currentName = currentNote
.replace(/\.md$/, "")
.split("/")
.pop()
?.toLowerCase();
.pop() || "";
if (!currentName) return;
// Read all notes and find backlinks
const allPaths = flattenNotes(notes);
const entries: BacklinkEntry[] = [];
for (const notePath of allPaths) {
if (notePath === currentNote) continue;
try {
const content = await readNote(vaultPath, notePath);
const links = extractWikilinks(content);
for (const link of links) {
if (link.target.toLowerCase() === currentName) {
const lines = content.split("\n");
const contextLine =
lines.find((l) => l.includes(link.raw)) || "";
entries.push({
sourcePath: notePath,
sourceName: notePath.replace(/\.md$/, "").split("/").pop() || notePath,
context: contextLine.trim().substring(0, 200),
});
try {
const contexts = await getBacklinkContext(vaultPath, currentName);
const entries: BacklinkEntry[] = contexts.map(c => ({
sourcePath: c.source_path,
sourceName: c.source_name,
context: c.excerpt,
}));
setBacklinks(entries);
} catch {
// Fallback: simple wikilink scan
const allPaths = flattenNotes(notes);
const entries: BacklinkEntry[] = [];
for (const notePath of allPaths) {
if (notePath === currentNote) continue;
try {
const content = await readNote(vaultPath, notePath);
const links = extractWikilinks(content);
for (const link of links) {
if (link.target.toLowerCase() === currentName.toLowerCase()) {
const lines = content.split("\n");
const contextLine = lines.find((l) => l.includes(link.raw)) || "";
entries.push({
sourcePath: notePath,
sourceName: notePath.replace(/\.md$/, "").split("/").pop() || notePath,
context: contextLine.trim().substring(0, 200),
});
}
}
}
} catch {
// Skip unreadable notes
} catch { }
}
setBacklinks(entries);
}
setBacklinks(entries);
})();
}, [vaultPath, currentNote, notes]);
@ -167,6 +269,65 @@ export default function App() {
[notes, vaultPath, navigate, refreshNotes]
);
// ── Global keyboard shortcuts ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
// ⌘⇧F — Focus mode
if (e.shiftKey && e.key.toLowerCase() === "f") {
e.preventDefault();
setFocusMode(v => !v);
return;
}
switch (e.key.toLowerCase()) {
case "k":
e.preventDefault();
setCmdPaletteOpen(v => !v);
break;
case "n":
e.preventDefault();
{
const name = prompt("Note name:");
if (name?.trim()) navigateToNote(name.trim());
}
break;
case "g":
e.preventDefault();
navigate("/graph");
break;
case "d":
e.preventDefault();
navigate("/daily");
break;
case "e":
e.preventDefault();
toggleEditMode();
break;
case "\\":
e.preventDefault();
toggleSidebar();
break;
case "t":
e.preventDefault();
setThemePickerOpen(v => !v);
break;
case "h":
e.preventDefault();
setSearchReplaceOpen(v => !v);
break;
case "u":
e.preventDefault();
setCssEditorOpen(v => !v);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [navigate, navigateToNote, toggleEditMode, toggleSidebar]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
@ -202,42 +363,47 @@ export default function App() {
setNoteContent,
backlinks,
navigateToNote,
sidebarOpen,
toggleSidebar,
editMode,
toggleEditMode,
splitNote,
setSplitNote,
favorites,
toggleFavorite,
recentNotes,
switchVault,
focusMode,
toggleFocusMode,
}}
>
<div className="flex h-screen w-screen overflow-hidden">
<Sidebar />
<div className={`flex h-screen w-screen overflow-hidden ${focusMode ? "focus-mode" : ""}`}>
{sidebarOpen && !focusMode && <Sidebar />}
<Routes>
<Route path="/" element={<WelcomeScreen />} />
<Route path="/note/:path" element={<NoteView />} />
<Route path="/note/:path" element={<SplitView />} />
<Route path="/daily" element={<DailyView />} />
<Route path="/graph" element={<GraphView />} />
<Route path="/calendar" element={<CalendarView />} />
<Route path="/kanban" element={<KanbanView />} />
<Route path="/flashcards" element={<FlashcardView />} />
<Route path="/whiteboard/:name" element={<Suspense fallback={<div className="flex-1 flex items-center justify-center"><p className="text-[var(--text-muted)]">Loading whiteboard...</p></div>}><WhiteboardView /></Suspense>} />
<Route path="/database" element={<DatabaseView />} />
<Route path="/timeline" element={<TimelineView />} />
<Route path="/analytics" element={<GraphAnalytics />} />
<Route path="/integrity" element={<IntegrityReport onClose={() => navigate('/')} />} />
<Route path="/audit-log" element={<AuditLog onClose={() => navigate('/')} />} />
</Routes>
</div>
<CommandPalette open={cmdPaletteOpen} onClose={() => setCmdPaletteOpen(false)} />
<LinkPreview />
<ThemePicker open={themePickerOpen} onClose={() => setThemePickerOpen(false)} />
<SearchReplace open={searchReplaceOpen} onClose={() => setSearchReplaceOpen(false)} />
<CSSEditor open={cssEditorOpen} onClose={() => setCssEditorOpen(false)} />
</VaultContext.Provider>
);
}
/* ── Note View ──────────────────────────────────────────────── */
function NoteView() {
const { path } = useParams<{ path: string }>();
const { vaultPath, setCurrentNote, noteContent, setNoteContent } = useVault();
const decodedPath = decodeURIComponent(path || "");
useEffect(() => {
if (!decodedPath || !vaultPath) return;
setCurrentNote(decodedPath);
readNote(vaultPath, decodedPath).then(setNoteContent).catch(() => setNoteContent(""));
}, [decodedPath, vaultPath, setCurrentNote, setNoteContent]);
return (
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
<Backlinks />
</div>
);
}
/* ── Daily View ─────────────────────────────────────────────── */
function DailyView() {
@ -249,6 +415,9 @@ function DailyView() {
getOrCreateDaily(vaultPath).then((dailyPath) => {
refreshNotes();
navigate(`/note/${encodeURIComponent(dailyPath)}`, { replace: true });
}).catch((e) => {
console.error("[GraphNotes] Failed to create daily note:", e);
navigate("/", { replace: true });
});
}, [vaultPath, navigate, refreshNotes]);

View file

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

View file

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

View file

@ -0,0 +1,83 @@
import { useState, useEffect, useRef } from "react";
import { getCustomCss, setCustomCss } from "../lib/commands";
/**
* CSSEditor Monaco-lite custom CSS editor with live preview.
*/
export function CSSEditor({ open, onClose }: { open: boolean; onClose: () => void }) {
const [css, setCss] = useState("");
const [saved, setSaved] = useState(false);
const styleRef = useRef<HTMLStyleElement | null>(null);
useEffect(() => {
if (open) {
getCustomCss().then(setCss).catch(() => setCss(""));
}
}, [open]);
// Live preview
useEffect(() => {
if (!styleRef.current) {
const el = document.createElement("style");
el.id = "custom-user-css";
document.head.appendChild(el);
styleRef.current = el;
}
styleRef.current.textContent = css;
}, [css]);
const handleSave = async () => {
try {
await setCustomCss(css);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
console.error("Save CSS failed:", e);
}
};
if (!open) return null;
return (
<div className="css-backdrop" onClick={onClose}>
<div className="css-modal" onClick={e => e.stopPropagation()}>
<div className="css-header">
<h3 className="css-title">🎨 Custom CSS</h3>
<div className="css-actions">
{saved && <span className="css-saved"> Saved</span>}
<button className="css-save-btn" onClick={handleSave}>Save</button>
<button className="css-close" onClick={onClose}></button>
</div>
</div>
<textarea
className="css-textarea"
value={css}
onChange={e => setCss(e.target.value)}
placeholder={`/* Your custom CSS */\n\n.editor-title {\n font-style: italic;\n}\n\n.sidebar {\n opacity: 0.9;\n}`}
spellCheck={false}
/>
<div className="css-hint">
Changes apply immediately. Save to persist across sessions.
</div>
</div>
</div>
);
}
/**
* Hook to load custom CSS on mount.
*/
export function useCustomCssInit() {
useEffect(() => {
getCustomCss().then(css => {
if (!css) return;
let el = document.getElementById("custom-user-css") as HTMLStyleElement;
if (!el) {
el = document.createElement("style");
el.id = "custom-user-css";
document.head.appendChild(el);
}
el.textContent = css;
}).catch(() => { });
}, []);
}

View file

@ -0,0 +1,122 @@
import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { listDailyNotes, getOrCreateDaily } from "../lib/commands";
/**
* CalendarView Visual month calendar for navigating daily notes.
* Dots indicate dates with existing daily notes.
*/
export function CalendarView() {
const { vaultPath } = useVault();
const navigate = useNavigate();
const [currentDate, setCurrentDate] = useState(new Date());
const [dailyDates, setDailyDates] = useState<Set<string>>(new Set());
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Load daily notes
useEffect(() => {
if (!vaultPath) return;
listDailyNotes(vaultPath)
.then(dates => setDailyDates(new Set(dates)))
.catch(() => setDailyDates(new Set()));
}, [vaultPath, month, year]);
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
// Calendar grid
const calendarDays = useMemo(() => {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
// Pad to complete weeks
while (days.length % 7 !== 0) days.push(null);
return days;
}, [year, month]);
const monthName = currentDate.toLocaleString("default", { month: "long" });
const handleDayClick = async (day: number) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const notePath = `daily/${dateStr}.md`;
if (dailyDates.has(dateStr)) {
navigate(`/note/${encodeURIComponent(notePath)}`);
} else {
// Create the daily note for this date
try {
await getOrCreateDaily(vaultPath);
navigate(`/note/${encodeURIComponent(notePath)}`);
} catch {
navigate(`/note/${encodeURIComponent(notePath)}`);
}
}
};
return (
<div className="calendar-view">
<div className="calendar-card">
{/* Header */}
<div className="calendar-header">
<button className="calendar-nav" onClick={() => setCurrentDate(new Date(year, month - 1))}>
</button>
<h2 className="calendar-title">{monthName} {year}</h2>
<button className="calendar-nav" onClick={() => setCurrentDate(new Date(year, month + 1))}>
</button>
</div>
{/* Today button */}
<div className="calendar-today-bar">
<button
className="calendar-today-btn"
onClick={() => setCurrentDate(new Date())}
>
Today
</button>
</div>
{/* Day headers */}
<div className="calendar-grid">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => (
<div key={d} className="calendar-day-header">{d}</div>
))}
{/* Day cells */}
{calendarDays.map((day, i) => {
if (day === null) {
return <div key={`empty-${i}`} className="calendar-day empty" />;
}
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const hasNote = dailyDates.has(dateStr);
const isToday = dateStr === todayStr;
return (
<button
key={dateStr}
className={`calendar-day ${isToday ? "today" : ""} ${hasNote ? "has-note" : ""}`}
onClick={() => handleDayClick(day)}
>
<span className="calendar-day-number">{day}</span>
{hasNote && <span className="calendar-dot" />}
</button>
);
})}
</div>
{/* Stats */}
<div className="calendar-stats">
<span>{dailyDates.size} daily note{dailyDates.size !== 1 ? "s" : ""}</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,322 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { searchVault, listTemplates, createFromTemplate, exportNoteHtml, type SearchResult, type TemplateInfo } from "../lib/commands";
interface CommandItem {
id: string;
icon: string;
label: string;
hint?: string;
action: () => void;
}
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
const { vaultPath, notes, navigateToNote, currentNote } = useVault();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [templates, setTemplates] = useState<TemplateInfo[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Load templates when opened
useEffect(() => {
if (open && vaultPath) {
listTemplates(vaultPath).then(setTemplates).catch(() => setTemplates([]));
}
}, [open, vaultPath]);
// Focus input when opened
useEffect(() => {
if (open) {
setQuery("");
setSelectedIndex(0);
setSearchResults([]);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Debounced search
useEffect(() => {
if (!query.trim() || query.length < 2) {
setSearchResults([]);
return;
}
const timer = setTimeout(async () => {
try {
const results = await searchVault(vaultPath, query);
setSearchResults(results.slice(0, 10));
} catch {
setSearchResults([]);
}
}, 200);
return () => clearTimeout(timer);
}, [query, vaultPath]);
// Build command list
const commands: CommandItem[] = [];
if (!query.trim()) {
commands.push(
{ id: "daily", icon: "📅", label: "Open Daily Note", hint: "⌘D", action: () => { navigate("/daily"); onClose(); } },
{ id: "graph", icon: "🔮", label: "Graph View", hint: "⌘G", action: () => { navigate("/graph"); onClose(); } },
{ id: "new", icon: "✏️", label: "New Note", hint: "⌘N", action: () => { const name = prompt("Note name:"); if (name?.trim()) { navigateToNote(name.trim()); onClose(); } } },
);
// Template commands
if (templates.length > 0) {
for (const t of templates) {
commands.push({
id: `template-${t.path}`,
icon: "📋",
label: `New from template: ${t.name}`,
hint: "_templates",
action: async () => {
const name = prompt(`Note name (from "${t.name}" template):`);
if (name?.trim()) {
try {
const path = await createFromTemplate(vaultPath, t.path, name.trim());
navigate(`/note/${encodeURIComponent(path)}`);
onClose();
} catch (e) {
console.error("Template creation failed:", e);
}
}
},
});
}
}
// v0.4 commands
commands.push(
{ id: "calendar", icon: "📆", label: "Calendar View", action: () => { navigate("/calendar"); onClose(); } },
{ id: "theme", icon: "🎨", label: "Change Theme", hint: "⌘T", action: () => { onClose(); } },
);
if (currentNote) {
commands.push({
id: "export-html",
icon: "📤",
label: "Export as HTML",
action: async () => {
try {
const html = await exportNoteHtml(vaultPath, currentNote);
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = currentNote.replace(/\.md$/, ".html");
a.click();
URL.revokeObjectURL(url);
onClose();
} catch (e) {
console.error("Export failed:", e);
}
},
});
}
// v0.5 commands
commands.push(
{ id: "kanban", icon: "📋", label: "Kanban Board", action: () => { navigate("/kanban"); onClose(); } },
{ id: "focus", icon: "🧘", label: "Focus Mode", hint: "⌘⇧F", action: () => { onClose(); } },
{ id: "search-replace", icon: "🔍", label: "Search & Replace", hint: "⌘H", action: () => { onClose(); } },
);
if (currentNote) {
commands.push({
id: "export-pdf",
icon: "📄",
label: "Export as PDF",
action: () => {
onClose();
setTimeout(() => window.print(), 200);
},
});
}
// v0.6 commands
commands.push(
{ id: "flashcards", icon: "🎴", label: "Flashcards", action: () => { navigate("/flashcards"); onClose(); } },
{ id: "custom-css", icon: "🎨", label: "Custom CSS", action: () => { onClose(); } },
{
id: "save-workspace", icon: "💾", label: "Save Workspace", action: () => {
const name = prompt("Workspace name:");
if (name?.trim()) { onClose(); }
}
},
);
// v0.7 commands
commands.push(
{ id: "database", icon: "📊", label: "Database View", action: () => { navigate("/database"); onClose(); } },
{
id: "whiteboard", icon: "🎨", label: "New Whiteboard", action: () => {
const name = prompt("Canvas name:");
if (name?.trim()) { navigate(`/whiteboard/${encodeURIComponent(name.trim())}`); onClose(); }
}
},
{ id: "git-sync", icon: "🔀", label: "Git Sync", action: () => { onClose(); } },
);
// v0.8 commands
commands.push(
{ id: "timeline", icon: "📅", label: "Timeline", action: () => { navigate("/timeline"); onClose(); } },
{
id: "random-note", icon: "🎲", label: "Random Note", action: async () => {
try {
const { randomNote } = await import("../lib/commands");
const name = await randomNote(vaultPath);
navigate(`/note/${encodeURIComponent(name)}`);
} catch { }
onClose();
}
},
);
// v0.9 commands
commands.push(
{ id: "analytics", icon: "📊", label: "Graph Analytics", action: () => { navigate("/analytics"); onClose(); } },
{ id: "import-export", icon: "📦", label: "Import / Export", action: () => { onClose(); } },
{ id: "shortcuts", icon: "⌨️", label: "Keyboard Shortcuts", action: () => { onClose(); } },
);
// v1.4 commands
commands.push(
{ id: "integrity", icon: "🛡️", label: "Integrity Report", action: () => { navigate("/integrity"); onClose(); } },
{ id: "audit-log", icon: "📋", label: "Audit Log", action: () => { navigate("/audit-log"); onClose(); } },
{ id: "create-backup", icon: "💾", label: "Create Backup", action: () => { onClose(); } },
{ id: "verify-vault", icon: "🔐", label: "Verify Vault Checksums", action: () => { navigate("/integrity"); onClose(); } },
);
}
// Note matches
const flatNotes = flattenNoteNames(notes);
const queryLower = query.toLowerCase();
if (query.trim()) {
const nameMatches = flatNotes
.filter(n => n.name.toLowerCase().includes(queryLower))
.slice(0, 5);
for (const note of nameMatches) {
commands.push({
id: `note-${note.path}`,
icon: "📄",
label: note.name,
hint: note.path,
action: () => { navigate(`/note/${encodeURIComponent(note.path)}`); onClose(); },
});
}
// Content search results
for (const result of searchResults) {
// Don't duplicate filename matches
if (commands.some(c => c.id === `note-${result.path}`)) continue;
commands.push({
id: `search-${result.path}`,
icon: "🔍",
label: result.name,
hint: result.context.substring(0, 80),
action: () => { navigate(`/note/${encodeURIComponent(result.path)}`); onClose(); },
});
}
}
// Clamp selection
const maxIndex = Math.max(0, commands.length - 1);
const safeIndex = Math.min(selectedIndex, maxIndex);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, maxIndex));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
commands[safeIndex]?.action();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}, [commands, safeIndex, maxIndex, onClose]);
// Scroll selected item into view
useEffect(() => {
const list = listRef.current;
if (!list) return;
const item = list.children[safeIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: "nearest" });
}, [safeIndex]);
if (!open) return null;
return (
<div className="cmd-backdrop" onClick={onClose}>
<div className="cmd-palette" onClick={e => e.stopPropagation()} onKeyDown={handleKeyDown}>
{/* Search input */}
<div className="cmd-input-wrap">
<svg className="cmd-input-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
ref={inputRef}
className="cmd-input"
type="text"
placeholder="Search notes, commands..."
value={query}
onChange={e => { setQuery(e.target.value); setSelectedIndex(0); }}
/>
<kbd className="cmd-kbd">ESC</kbd>
</div>
{/* Results */}
<div className="cmd-list" ref={listRef}>
{commands.length === 0 && query.trim() && (
<div className="cmd-empty">No results for "{query}"</div>
)}
{commands.map((cmd, i) => (
<button
key={cmd.id}
className={`cmd-item ${i === safeIndex ? "selected" : ""}`}
onClick={cmd.action}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="cmd-item-icon">{cmd.icon}</span>
<span className="cmd-item-label">{cmd.label}</span>
{cmd.hint && <span className="cmd-item-hint">{cmd.hint}</span>}
</button>
))}
</div>
{/* Footer */}
<div className="cmd-footer">
<span> navigate</span>
<span> select</span>
<span>esc close</span>
</div>
</div>
</div>
);
}
/* ── Helpers ────────────────────────────────────────────────── */
function flattenNoteNames(
entries: { name: string; path: string; is_dir: boolean; children?: any[] }[]
): { name: string; path: string }[] {
const result: { name: string; path: string }[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
result.push(...flattenNoteNames(entry.children));
} else if (!entry.is_dir) {
result.push({ name: entry.name, path: entry.path });
}
}
return result;
}

View file

@ -0,0 +1,75 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface MenuItem {
label: string;
icon: string;
action: () => void;
danger?: boolean;
}
interface ContextMenuProps {
items: MenuItem[];
position: { x: number; y: number } | null;
onClose: () => void;
}
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!position) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
const escHandler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", escHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", escHandler);
};
}, [position, onClose]);
if (!position) return null;
return (
<div
ref={ref}
className="context-menu"
style={{ top: position.y, left: position.x }}
>
{items.map((item, i) => (
<button
key={i}
className={`context-menu-item ${item.danger ? "danger" : ""}`}
onClick={() => { item.action(); onClose(); }}
>
<span className="context-menu-icon">{item.icon}</span>
<span>{item.label}</span>
</button>
))}
</div>
);
}
/* Hook: useContextMenu */
export function useContextMenu() {
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const [menuTarget, setMenuTarget] = useState<string | null>(null);
const openMenu = useCallback((e: React.MouseEvent, target: string) => {
e.preventDefault();
e.stopPropagation();
setMenuPos({ x: e.clientX, y: e.clientY });
setMenuTarget(target);
}, []);
const closeMenu = useCallback(() => {
setMenuPos(null);
setMenuTarget(null);
}, []);
return { menuPos, menuTarget, openMenu, closeMenu };
}

View file

@ -0,0 +1,171 @@
import { useEffect, useState, useMemo } from "react";
import { useVault } from "../App";
import { queryFrontmatter, type FrontmatterRow } from "../lib/commands";
type ViewMode = "table" | "gallery" | "list";
/**
* DatabaseView Notion-style table/gallery/list views from frontmatter.
*/
export function DatabaseView() {
const { vaultPath, navigateToNote } = useVault();
const [rows, setRows] = useState<FrontmatterRow[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [sortField, setSortField] = useState<string>("title");
const [sortAsc, setSortAsc] = useState(true);
const [filterField, setFilterField] = useState("");
const [filterValue, setFilterValue] = useState("");
useEffect(() => {
if (!vaultPath) return;
queryFrontmatter(vaultPath).then(setRows).catch(() => setRows([]));
}, [vaultPath]);
// All unique field keys
const allFields = useMemo(() => {
const keys = new Set<string>();
rows.forEach(r => Object.keys(r.fields).forEach(k => keys.add(k)));
return ["title", ...Array.from(keys)];
}, [rows]);
// Sort + Filter
const processed = useMemo(() => {
let data = [...rows];
if (filterField && filterValue) {
data = data.filter(r => {
const val = filterField === "title"
? r.title
: (r.fields[filterField] || "");
return val.toLowerCase().includes(filterValue.toLowerCase());
});
}
data.sort((a, b) => {
const va = sortField === "title" ? a.title : (a.fields[sortField] || "");
const vb = sortField === "title" ? b.title : (b.fields[sortField] || "");
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
});
return data;
}, [rows, sortField, sortAsc, filterField, filterValue]);
const handleSort = (field: string) => {
if (sortField === field) setSortAsc(!sortAsc);
else { setSortField(field); setSortAsc(true); }
};
return (
<div className="database-view">
<div className="database-header">
<h2 className="database-title">📊 Database</h2>
<div className="database-controls">
<div className="database-filter">
<select
className="database-select"
value={filterField}
onChange={e => setFilterField(e.target.value)}
>
<option value="">Filter by</option>
{allFields.map(f => <option key={f} value={f}>{f}</option>)}
</select>
{filterField && (
<input
className="database-filter-input"
value={filterValue}
onChange={e => setFilterValue(e.target.value)}
placeholder="Contains…"
/>
)}
</div>
<div className="database-mode-group">
{(["table", "gallery", "list"] as ViewMode[]).map(m => (
<button
key={m}
className={`database-mode-btn ${viewMode === m ? "active" : ""}`}
onClick={() => setViewMode(m)}
>
{m === "table" ? "📋" : m === "gallery" ? "🖼️" : "📝"} {m}
</button>
))}
</div>
</div>
</div>
{viewMode === "table" && (
<div className="database-table-wrapper">
<table className="database-table">
<thead>
<tr>
{allFields.map(f => (
<th key={f} onClick={() => handleSort(f)} className="database-th">
{f}
{sortField === f && <span>{sortAsc ? " ↑" : " ↓"}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{processed.map(row => (
<tr
key={row.path}
className="database-row"
onClick={() => navigateToNote(row.title)}
>
{allFields.map(f => (
<td key={f} className="database-td">
{f === "title" ? row.title : (row.fields[f] || "—")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{viewMode === "gallery" && (
<div className="database-gallery">
{processed.map(row => (
<div
key={row.path}
className="database-gallery-card"
onClick={() => navigateToNote(row.title)}
>
<div className="gallery-card-title">{row.title}</div>
<div className="gallery-card-fields">
{Object.entries(row.fields).slice(0, 3).map(([k, v]) => (
<div key={k} className="gallery-card-field">
<span className="gallery-field-key">{k}:</span>
<span className="gallery-field-val">{v}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
{viewMode === "list" && (
<div className="database-list">
{processed.map(row => (
<div
key={row.path}
className="database-list-item"
onClick={() => navigateToNote(row.title)}
>
<span className="database-list-name">{row.title}</span>
<span className="database-list-meta">
{Object.entries(row.fields).slice(0, 2).map(([k, v]) => `${k}: ${v}`).join(" • ")}
</span>
</div>
))}
</div>
)}
{processed.length === 0 && (
<div className="database-empty">
<p>No notes with frontmatter found.</p>
<p>Add YAML frontmatter between <code>---</code> markers to your notes.</p>
</div>
)}
</div>
);
}

View file

@ -1,8 +1,16 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { useVault } from "../App";
import { writeNote } from "../lib/commands";
import { writeNote, saveAttachment, saveSnapshot, getWritingGoal, setWritingGoal as setWritingGoalCmd, isEncrypted, encryptNote, decryptNote } from "../lib/commands";
import { extractWikilinks } from "../lib/wikilinks";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { PropertiesPanel } from "./PropertiesPanel";
import { TableOfContents } from "./TableOfContents";
import { SlashMenu } from "./SlashMenu";
import { HistoryPanel } from "./HistoryPanel";
import { MiniGraph } from "./MiniGraph";
import { LockButton, LockScreen } from "./LockScreen";
import { RefactorMenu } from "./RefactorMenu";
interface AutocompleteState {
active: boolean;
@ -13,18 +21,30 @@ interface AutocompleteState {
}
export function Editor() {
const { vaultPath, currentNote, noteContent, setNoteContent, navigateToNote, notes } = useVault();
const { vaultPath, currentNote, noteContent, setNoteContent, navigateToNote, notes, editMode, toggleEditMode, focusMode } = useVault();
const editorRef = useRef<HTMLDivElement>(null);
const ceRef = useRef<HTMLDivElement>(null); // contenteditable
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const lastNoteRef = useRef<string | null>(null);
const isComposingRef = useRef(false);
const [isPreview, setIsPreview] = useState(false);
const isPreview = !editMode;
const [isSaving, setIsSaving] = useState(false);
const [autocomplete, setAutocomplete] = useState<AutocompleteState>({
active: false, query: "", range: null, selectedIndex: 0,
position: { top: 0, left: 0 },
});
const [slashMenu, setSlashMenu] = useState<{ visible: boolean; query: string; position: { top: number; left: number } }>({
visible: false, query: "", position: { top: 0, left: 0 },
});
const [historyOpen, setHistoryOpen] = useState(false);
const [writingGoal, setWritingGoal] = useState(0);
const [goalEditing, setGoalEditing] = useState(false);
const lastSnapshotRef = useRef(0);
const mermaidRef = useRef<HTMLDivElement>(null);
const [noteEncrypted, setNoteEncrypted] = useState(false);
const [lockScreenOpen, setLockScreenOpen] = useState(false);
const [lockError, setLockError] = useState<string | null>(null);
const [refactorMenu, setRefactorMenu] = useState({ visible: false, position: { top: 0, left: 0 }, text: "" });
const noteName = currentNote
?.replace(/\.md$/, "")
@ -41,20 +61,37 @@ export function Editor() {
)
: [];
// Refs to avoid stale closures in debounced save
const currentNoteRef = useRef(currentNote);
const vaultPathRef = useRef(vaultPath);
currentNoteRef.current = currentNote;
vaultPathRef.current = vaultPath;
const lastSnapshotRef2 = useRef(0);
// ── Save with debounce ──
const saveContent = useCallback(
(value: string) => {
setNoteContent(value);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
// Capture the note path NOW (at call time), not when the timer fires.
// This prevents writing old content to a different note after switching.
const capturedNote = currentNoteRef.current;
const capturedVault = vaultPathRef.current;
saveTimeoutRef.current = setTimeout(async () => {
if (vaultPath && currentNote) {
if (capturedVault && capturedNote) {
setIsSaving(true);
await writeNote(vaultPath, currentNote, value);
await writeNote(capturedVault, capturedNote, value);
setIsSaving(false);
// Auto-snapshot on save (max 1 per 5 min)
const now = Date.now();
if (now - lastSnapshotRef2.current > 5 * 60 * 1000 && value.length > 50) {
lastSnapshotRef2.current = now;
saveSnapshot(capturedVault, capturedNote).catch(() => { });
}
}
}, 500);
},
[vaultPath, currentNote, setNoteContent]
[setNoteContent]
);
// ── Extract raw markdown from contenteditable DOM ──
@ -71,9 +108,21 @@ export function Editor() {
}, []);
// ── Initialize / switch note ──
const contentRenderedRef = useRef(false);
useEffect(() => {
if (currentNote !== lastNoteRef.current) {
// Cancel any pending debounced save from the previous note
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = undefined;
}
lastNoteRef.current = currentNote;
contentRenderedRef.current = false;
renderToDOM(noteContent);
if (noteContent) contentRenderedRef.current = true;
} else if (!contentRenderedRef.current && noteContent) {
// Content arrived async after note switch — render it now
contentRenderedRef.current = true;
renderToDOM(noteContent);
}
}, [currentNote, noteContent, renderToDOM]);
@ -226,6 +275,64 @@ export function Editor() {
}
}
// ── List continuation: Enter after "- item" inserts "- " on next line ──
if (e.key === "Enter" && !e.shiftKey) {
const raw = extractRaw();
const lines = raw.split("\n");
// Find current line (approximate: count \n before cursor)
const sel2 = window.getSelection();
if (sel2 && sel2.focusNode) {
const fullText = ceRef.current?.textContent || "";
// crude position estimate
let pos = 0;
const walk = (node: Node): boolean => {
if (node === sel2.focusNode) {
pos += sel2.focusOffset;
return true;
}
if (node.nodeType === Node.TEXT_NODE) {
pos += (node.textContent || "").length;
} else {
for (const child of Array.from(node.childNodes)) {
if (walk(child)) return true;
}
}
return false;
};
if (ceRef.current) walk(ceRef.current);
const textUpToCursor = fullText.substring(0, pos);
const lastNewline = textUpToCursor.lastIndexOf("\n");
const currentLine = textUpToCursor.substring(lastNewline + 1);
const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/);
if (listMatch) {
// If line is ONLY the bullet (empty item), remove it
if (currentLine.trim() === listMatch[2]) {
// Don't continue, let default handle
} else {
e.preventDefault();
document.execCommand("insertText", false, "\n" + listMatch[1] + listMatch[2] + " ");
const newRaw = extractRaw();
saveContent(newRaw);
return;
}
}
}
}
// ── Tab indent/outdent for list items ──
if (e.key === "Tab") {
e.preventDefault();
if (e.shiftKey) {
document.execCommand("outdent");
} else {
document.execCommand("insertText", false, " ");
}
const raw = extractRaw();
saveContent(raw);
return;
}
// Token unwrap on Backspace/Delete
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
@ -325,6 +432,218 @@ export function Editor() {
return () => document.removeEventListener("mousedown", handler);
}, []);
const renderedMarkdown = currentNote ? (() => {
let html = marked(noteContent, { async: false }) as string;
html = html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
const safeTarget = target.trim().replace(/"/g, '&quot;');
return `<span class="wikilink" data-target="${safeTarget}">${DOMPurify.sanitize(label)}</span>`;
}
);
return DOMPurify.sanitize(html, { ADD_ATTR: ['data-target'] });
})() : "";
// Mermaid post-processing (render in a separate effect)
useEffect(() => {
if (!isPreview || !mermaidRef.current) return;
const mermaidBlocks = mermaidRef.current.querySelectorAll<HTMLElement>("code.language-mermaid");
if (mermaidBlocks.length === 0) return;
// Lazy load mermaid
import("mermaid").then(mod => {
const mermaid = mod.default;
mermaid.initialize({ startOnLoad: false, theme: "dark" });
mermaidBlocks.forEach(async (block, i) => {
const pre = block.parentElement;
if (!pre) return;
const code = block.textContent || "";
try {
const { svg } = await mermaid.render(`mermaid-${i}`, code);
pre.outerHTML = `<div class="mermaid-diagram">${svg}</div>`;
} catch {
// leave as code block if mermaid fails
}
});
}).catch(() => { });
}, [isPreview, renderedMarkdown]);
// highlight.js for code blocks
useEffect(() => {
if (!isPreview || !mermaidRef.current) return;
import("highlight.js/lib/core").then(async (hljs) => {
const hljsCore = hljs.default;
// Lazy-load common languages
const [js, ts, css, json, py, rust, bash, md] = await Promise.all([
import("highlight.js/lib/languages/javascript"),
import("highlight.js/lib/languages/typescript"),
import("highlight.js/lib/languages/css"),
import("highlight.js/lib/languages/json"),
import("highlight.js/lib/languages/python"),
import("highlight.js/lib/languages/rust"),
import("highlight.js/lib/languages/bash"),
import("highlight.js/lib/languages/markdown"),
]);
hljsCore.registerLanguage("javascript", js.default);
hljsCore.registerLanguage("typescript", ts.default);
hljsCore.registerLanguage("css", css.default);
hljsCore.registerLanguage("json", json.default);
hljsCore.registerLanguage("python", py.default);
hljsCore.registerLanguage("rust", rust.default);
hljsCore.registerLanguage("bash", bash.default);
hljsCore.registerLanguage("markdown", md.default);
const blocks = mermaidRef.current?.querySelectorAll<HTMLElement>("pre code:not(.language-mermaid)") || [];
blocks.forEach(block => {
hljsCore.highlightElement(block);
// Add copy button
const pre = block.parentElement;
if (pre && !pre.querySelector(".code-copy-btn")) {
const btn = document.createElement("button");
btn.className = "code-copy-btn";
btn.textContent = "Copy";
btn.onclick = () => {
navigator.clipboard.writeText(block.textContent || "");
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
};
pre.style.position = "relative";
pre.appendChild(btn);
}
});
}).catch(() => { });
}, [isPreview, renderedMarkdown]);
// Snapshot logic is now in the saveContent debounce callback above
// Load writing goal
useEffect(() => {
if (!vaultPath || !currentNote) { setWritingGoal(0); return; }
getWritingGoal(vaultPath, currentNote).then(setWritingGoal).catch(() => setWritingGoal(0));
}, [vaultPath, currentNote]);
// Check if note is encrypted
useEffect(() => {
if (!vaultPath || !currentNote) { setNoteEncrypted(false); return; }
isEncrypted(vaultPath, currentNote).then(setNoteEncrypted).catch(() => setNoteEncrypted(false));
}, [vaultPath, currentNote]);
// Widget rendering in preview (single pass to avoid clobbering DOM)
useEffect(() => {
if (!isPreview || !mermaidRef.current) return;
const container = mermaidRef.current;
let html = container.innerHTML;
html = html
.replace(/\{\{progress:(\d+)\}\}/g, (_, n) => `<div class="widget-progress"><div class="widget-progress-fill" style="width:${Math.min(100, +n)}%"></div><span class="widget-progress-label">${n}%</span></div>`)
.replace(/\{\{counter:(\d+)\}\}/g, (_, n) => `<span class="widget-counter">${n}</span>`)
.replace(/\{\{toggle:(on|off)\}\}/g, (_, state) => `<span class="widget-toggle ${state === 'on' ? 'on' : ''}">${state === 'on' ? '●' : '○'}</span>`);
if (html !== container.innerHTML) container.innerHTML = html;
}, [isPreview, renderedMarkdown]);
// Right-click for refactoring
const handleContextMenu = useCallback((e: React.MouseEvent) => {
const sel = window.getSelection();
const text = sel?.toString() || "";
if (text.trim() || currentNote) {
e.preventDefault();
setRefactorMenu({ visible: true, position: { top: e.clientY, left: e.clientX }, text });
}
}, [currentNote]);
// Image paste handler
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
if (!file || !vaultPath) return;
const buffer = await file.arrayBuffer();
const data = Array.from(new Uint8Array(buffer));
const ext = file.type.split("/")[1] || "png";
const fileName = `paste-${Date.now()}.${ext}`;
try {
const relPath = await saveAttachment(vaultPath, fileName, data);
// Insert markdown image at cursor
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const imgText = `![${fileName}](${relPath})`;
const textNode = document.createTextNode(imgText);
range.insertNode(textNode);
range.collapse(false);
}
// Trigger save using same extraction as regular input
const raw = domToMarkdown(ceRef.current!);
saveContent(raw);
} catch (err) {
console.error("Image paste failed:", err);
}
return;
}
}
}, [vaultPath, saveContent]);
// Slash command trigger
const checkSlashCommand = useCallback(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent || "";
const offset = range.startOffset;
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
const lineText = text.substring(lineStart, offset);
if (lineText.startsWith("/")) {
const query = lineText.substring(1);
const rect = range.getBoundingClientRect();
setSlashMenu({
visible: true,
query,
position: { top: rect.bottom + 4, left: rect.left },
});
} else {
setSlashMenu(prev => ({ ...prev, visible: false }));
}
}, []);
const handleSlashSelect = useCallback((insert: string) => {
setSlashMenu(prev => ({ ...prev, visible: false }));
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent || "";
const offset = range.startOffset;
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
// Replace the /query with the insert text
node.textContent = text.substring(0, lineStart) + insert + text.substring(offset);
// Move cursor after insert
const newOffset = lineStart + insert.length;
range.setStart(node, Math.min(newOffset, node.textContent.length));
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
// Save through the standard debounced pipeline
const raw = domToMarkdown(ceRef.current!);
saveContent(raw);
}, [saveContent]);
// Breadcrumb segments
const breadcrumbs = currentNote
? currentNote.replace(/\.md$/, "").split("/")
: [];
if (!currentNote) {
return (
<div className="flex-1 flex items-center justify-center">
@ -333,22 +652,27 @@ export function Editor() {
);
}
const renderedMarkdown = (() => {
let html = marked(noteContent, { async: false }) as string;
html = html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
return `<span class="wikilink" data-target="${target.trim()}">${label}</span>`;
}
);
return html;
})();
return (
<div className="flex flex-col h-full" ref={editorRef}>
<div className={`flex flex-col h-full ${focusMode ? "editor-focus" : ""}`} ref={editorRef}>
{/* ── Breadcrumbs ── */}
{!focusMode && breadcrumbs.length > 1 && (
<div className="breadcrumb-bar">
{breadcrumbs.map((seg, i) => (
<span key={i}>
{i > 0 && <span className="breadcrumb-separator">/</span>}
<span className={`breadcrumb-item ${i === breadcrumbs.length - 1 ? "active" : ""}`}>
{seg}
</span>
</span>
))}
</div>
)}
{/* ── Properties Panel ── */}
{!focusMode && <PropertiesPanel />}
{/* ── Header ── */}
<div className="editor-header">
<div className={`editor-header ${focusMode ? "editor-header-focus" : ""}`}>
<div className="flex items-center gap-3">
<h2 className="editor-title">{noteName}</h2>
{isSaving && (
@ -365,32 +689,66 @@ export function Editor() {
<div className="toggle-group">
<button
className={`toggle-item ${!isPreview ? "active" : ""}`}
onClick={() => setIsPreview(false)}
onClick={() => { if (isPreview) toggleEditMode(); }}
>
Edit
</button>
<button
className={`toggle-item ${isPreview ? "active" : ""}`}
onClick={() => setIsPreview(true)}
onClick={() => { if (!isPreview) toggleEditMode(); }}
>
Preview
</button>
</div>
{!focusMode && (
<button className={`history-toggle-btn ${historyOpen ? "active" : ""}`} onClick={() => setHistoryOpen(v => !v)} title="Version history">
🕐
</button>
)}
{!focusMode && (
<LockButton
isLocked={noteEncrypted}
onLock={async () => {
const pw = prompt("Set password:");
if (pw && currentNote) {
await encryptNote(vaultPath, currentNote, pw).catch(() => { });
setNoteEncrypted(true);
}
}}
onUnlock={() => setLockScreenOpen(true)}
/>
)}
</div>
</div>
{/* ── Writing Goal Progress ── */}
{writingGoal > 0 && (
<div className="writing-goal-bar">
<div className="writing-goal-fill" style={{ width: `${Math.min(100, (wordCount / writingGoal) * 100)}%` }} />
<span className="writing-goal-text">{wordCount} / {writingGoal} words</span>
</div>
)}
{/* ── Content ── */}
<div className="flex-1 overflow-y-auto relative">
{isPreview ? (
<div
className="prose prose-invert max-w-none px-8 py-6"
style={{
color: "var(--text-primary)",
lineHeight: 1.8,
fontSize: "15px",
}}
dangerouslySetInnerHTML={{ __html: renderedMarkdown }}
/>
<div className="flex h-full">
<div className="flex flex-1 overflow-y-auto">
<div
ref={mermaidRef}
className="prose prose-invert max-w-none px-8 py-6 flex-1"
style={{
color: "var(--text-primary)",
lineHeight: 1.8,
fontSize: "15px",
}}
dangerouslySetInnerHTML={{ __html: renderedMarkdown }}
/>
{!focusMode && <TableOfContents />}
{!focusMode && <MiniGraph />}
</div>
{historyOpen && !focusMode && <HistoryPanel onClose={() => setHistoryOpen(false)} />}
</div>
) : (
<>
{/* Contenteditable editor */}
@ -399,9 +757,10 @@ export function Editor() {
contentEditable
suppressContentEditableWarning
className="editor-ce"
onInput={handleInput}
onInput={() => { handleInput(); checkSlashCommand(); }}
onKeyDown={handleKeyDown}
onClick={handleClick}
onPaste={handlePaste}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => {
isComposingRef.current = false;
@ -462,9 +821,47 @@ export function Editor() {
<div className="autocomplete-hint">Enter to create Esc close</div>
</div>
)}
{/* ── Slash Command Menu ── */}
<SlashMenu
visible={slashMenu.visible}
position={slashMenu.position}
query={slashMenu.query}
onSelect={handleSlashSelect}
onClose={() => setSlashMenu(prev => ({ ...prev, visible: false }))}
/>
</>
)}
</div>
{/* ── Refactor Menu ── */}
<RefactorMenu
visible={refactorMenu.visible}
position={refactorMenu.position}
selectedText={refactorMenu.text}
onClose={() => setRefactorMenu({ visible: false, position: { top: 0, left: 0 }, text: "" })}
/>
{/* ── Lock Screen ── */}
{lockScreenOpen && (
<LockScreen
error={lockError}
onCancel={() => { setLockScreenOpen(false); setLockError(null); }}
onUnlock={async (pw) => {
if (!currentNote) return;
try {
const content = await decryptNote(vaultPath, currentNote, pw);
setNoteContent(content);
setNoteEncrypted(false);
setLockScreenOpen(false);
setLockError(null);
// Note: decrypted content is only held in memory, not written to disk
} catch {
setLockError("Wrong password");
}
}}
/>
)}
</div>
);
}
@ -534,26 +931,60 @@ function domToMarkdown(el: HTMLDivElement): string {
/** Convert raw markdown to tokenized HTML for contenteditable */
function markdownToTokenHTML(raw: string): string {
// Escape HTML
let escaped = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Process line by line for heading detection
const lines = raw.split("\n");
const htmlLines = lines.map(line => {
// Escape HTML
let escaped = line
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Replace [[wikilinks]] with token spans
escaped = escaped.replace(
/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
const rawAttr = _m.replace(/"/g, "&quot;");
return `<span class="wikilink-token" contenteditable="false" data-raw="${rawAttr}" data-target="${target.trim()}">${label}</span>`;
// Replace [[wikilinks]] with token spans
escaped = escaped.replace(
/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
const rawAttr = _m.replace(/"/g, "&quot;");
return `<span class="wikilink-token" contenteditable="false" data-raw="${rawAttr}" data-target="${target.trim()}">${label}</span>`;
}
);
// Replace #tags with tag tokens (but not inside wikilinks or at start of heading)
escaped = escaped.replace(
/(?:^|(?<=\s))(#[a-zA-Z][a-zA-Z0-9_/-]*)/g,
'<span class="tag-token">$1</span>'
);
// Inline code: `code`
escaped = escaped.replace(
/`([^`]+)`/g,
'<span class="inline-code">$1</span>'
);
// Bold: **text**
escaped = escaped.replace(
/\*\*([^*]+)\*\*/g,
'<span class="inline-bold">$1</span>'
);
// Italic: *text* (but not **)
escaped = escaped.replace(
/(?<!\*)\*([^*]+)\*(?!\*)/g,
'<span class="inline-italic">$1</span>'
);
// Detect heading level
const headingMatch = escaped.match(/^(#{1,3})\s/);
if (headingMatch) {
const level = headingMatch[1].length;
return `<div data-heading="${level}">${escaped}</div>`;
}
);
// Convert newlines to <br>
escaped = escaped.replace(/\n/g, "<br>");
return escaped;
});
return escaped;
return htmlLines.join("<br>");
}
/** Flatten note entries to a list of display names */

View file

@ -0,0 +1,105 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useVault } from "../App";
import { listFlashcards, updateCardSchedule, type Flashcard } from "../lib/commands";
/**
* FlashcardView Spaced repetition study mode.
*/
export function FlashcardView() {
const { vaultPath } = useVault();
const [cards, setCards] = useState<Flashcard[]>([]);
const [index, setIndex] = useState(0);
const [flipped, setFlipped] = useState(false);
const [loading, setLoading] = useState(true);
const [sessionDone, setSessionDone] = useState(0);
const loadCards = useCallback(async () => {
if (!vaultPath) return;
setLoading(true);
try {
const all = await listFlashcards(vaultPath);
const today = new Date().toISOString().slice(0, 10);
// Show cards due today or with no due date
const due = all.filter(c => !c.due || c.due <= today);
setCards(due.length > 0 ? due : all);
} catch {
setCards([]);
}
setLoading(false);
}, [vaultPath]);
useEffect(() => { loadCards(); }, [loadCards]);
const current = cards[index];
const handleRate = useCallback(async (quality: number) => {
if (!current || !vaultPath) return;
const cardId = `${current.source_path}:${current.line_number}`;
await updateCardSchedule(vaultPath, cardId, quality).catch(() => { });
setSessionDone(n => n + 1);
setFlipped(false);
if (index + 1 < cards.length) {
setIndex(i => i + 1);
} else {
setIndex(0);
loadCards(); // Refresh for next round
}
}, [current, vaultPath, index, cards.length, loadCards]);
if (loading) {
return <div className="flashcard-view"><div className="flashcard-loading">Loading flashcards</div></div>;
}
if (cards.length === 0) {
return (
<div className="flashcard-view">
<div className="flashcard-empty">
<div className="flashcard-empty-icon">🎴</div>
<h3>No flashcards found</h3>
<p>Add <code>?? question :: answer ??</code> to your notes</p>
</div>
</div>
);
}
return (
<div className="flashcard-view">
<div className="flashcard-header">
<span className="flashcard-progress">{index + 1} / {cards.length}</span>
<span className="flashcard-done">{sessionDone} reviewed</span>
</div>
<div
className={`flashcard-card ${flipped ? "flipped" : ""}`}
onClick={() => setFlipped(!flipped)}
>
<div className="flashcard-front">
<div className="flashcard-label">Question</div>
<div className="flashcard-text">{current.question}</div>
<div className="flashcard-hint">Click to reveal</div>
</div>
<div className="flashcard-back">
<div className="flashcard-label">Answer</div>
<div className="flashcard-text">{current.answer}</div>
</div>
</div>
{flipped && (
<div className="flashcard-ratings">
<span className="flashcard-ratings-label">How well did you know this?</span>
<div className="flashcard-buttons">
<button className="flashcard-btn rate-1" onClick={() => handleRate(1)}>Again</button>
<button className="flashcard-btn rate-2" onClick={() => handleRate(2)}>Hard</button>
<button className="flashcard-btn rate-3" onClick={() => handleRate(3)}>Good</button>
<button className="flashcard-btn rate-4" onClick={() => handleRate(4)}>Easy</button>
<button className="flashcard-btn rate-5" onClick={() => handleRate(5)}>Perfect</button>
</div>
</div>
)}
<div className="flashcard-source">
From: {current.source_path.replace(".md", "")}
</div>
</div>
);
}

160
src/components/GitPanel.tsx Normal file
View file

@ -0,0 +1,160 @@
import { useState, useEffect, useCallback } from "react";
import { useVault } from "../App";
import { gitStatus, gitCommit, gitPull, gitPush, gitInit } from "../lib/commands";
interface GitFile {
status: string;
path: string;
}
/**
* GitPanel Git sync sidebar panel.
*/
export function GitPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
const { vaultPath } = useVault();
const [files, setFiles] = useState<GitFile[]>([]);
const [message, setMessage] = useState("");
const [output, setOutput] = useState("");
const [isRepo, setIsRepo] = useState(true);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
if (!vaultPath) return;
try {
const status = await gitStatus(vaultPath);
const parsed: GitFile[] = status
.split("\n")
.filter(l => l.trim())
.map(l => ({
status: l.substring(0, 2).trim(),
path: l.substring(3).trim(),
}));
setFiles(parsed);
setIsRepo(true);
} catch {
setIsRepo(false);
setFiles([]);
}
}, [vaultPath]);
useEffect(() => { if (open) refresh(); }, [open, refresh]);
const handleCommit = async () => {
if (!vaultPath || !message.trim()) return;
setLoading(true);
try {
const result = await gitCommit(vaultPath, message.trim());
setOutput(result);
setMessage("");
await refresh();
} catch (e) {
setOutput(`Error: ${e}`);
}
setLoading(false);
};
const handlePull = async () => {
if (!vaultPath) return;
setLoading(true);
try {
const result = await gitPull(vaultPath);
setOutput(result);
await refresh();
} catch (e) {
setOutput(`Error: ${e}`);
}
setLoading(false);
};
const handlePush = async () => {
if (!vaultPath) return;
setLoading(true);
try {
const result = await gitPush(vaultPath);
setOutput(result);
} catch (e) {
setOutput(`Error: ${e}`);
}
setLoading(false);
};
const handleInit = async () => {
if (!vaultPath) return;
try {
await gitInit(vaultPath);
setIsRepo(true);
await refresh();
} catch (e) {
setOutput(`Error: ${e}`);
}
};
if (!open) return null;
return (
<div className="git-panel">
<div className="git-header">
<h3 className="git-title">🔀 Git Sync</h3>
<button className="git-close" onClick={onClose}></button>
</div>
{!isRepo ? (
<div className="git-init-prompt">
<p>This vault is not a git repository.</p>
<button className="git-init-btn" onClick={handleInit}>Initialize Git</button>
</div>
) : (
<>
<div className="git-status">
<div className="git-status-label">
{files.length === 0 ? "✅ Clean" : `${files.length} changed`}
</div>
<div className="git-file-list">
{files.map((f, i) => (
<div key={i} className="git-file">
<span className={`git-file-status status-${f.status.charAt(0).toLowerCase()}`}>
{f.status}
</span>
<span className="git-file-path">{f.path}</span>
</div>
))}
</div>
</div>
<div className="git-commit-area">
<input
className="git-message"
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Commit message…"
onKeyDown={e => e.key === "Enter" && handleCommit()}
/>
<button
className="git-commit-btn"
onClick={handleCommit}
disabled={!message.trim() || loading}
>
Commit
</button>
</div>
<div className="git-sync-actions">
<button className="git-pull-btn" onClick={handlePull} disabled={loading}>
Pull
</button>
<button className="git-push-btn" onClick={handlePush} disabled={loading}>
Push
</button>
<button className="git-refresh-btn" onClick={refresh}>
🔄 Refresh
</button>
</div>
{output && (
<pre className="git-output">{output}</pre>
)}
</>
)}
</div>
);
}

View file

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

View file

@ -1,700 +1,195 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Canvas,
CanvasProvider as _CanvasProvider,
registerNodeType,
ViewportControls,
} from "@blinksgg/canvas";
// @ts-ignore -- subpath export types not emitted
import { InMemoryStorageAdapter } from "@blinksgg/canvas/db";
import { useForceLayout, useFitToBounds, FitToBoundsMode } from "@blinksgg/canvas/hooks";
import { useVault } from "../App";
import { buildGraph, readNote, type GraphData } from "../lib/commands";
import { buildGraph, type GraphData } from "../lib/commands";
import { detectClusters, clusterColors } from "../lib/clustering";
import { NoteGraphNode } from "./NoteGraphNode";
// Cast to bypass dist/source type mismatch
const CanvasProviderAny = _CanvasProvider as any;
// Register custom node type
registerNodeType("note", NoteGraphNode as any);
/**
* GraphView Force-directed graph with semantic zoom.
* At low zoom: compact circles. At high zoom: morphs into
* rounded-rectangle cards showing note previews.
* GraphView Note graph powered by @blinksgg/canvas v3.0.
*/
interface SimNode {
id: string;
label: string;
path: string;
linkCount: number;
x: number;
y: number;
vx: number;
vy: number;
baseRadius: number;
color: string;
pinned: boolean;
preview: string; // First few lines of content
}
interface SimEdge {
source: string;
target: string;
}
const NODE_COLORS = [
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e",
"#06b6d4", "#a855f7", "#ec4899", "#14b8a6", "#ef4444",
];
export function GraphView() {
const { vaultPath } = useVault();
const navigate = useNavigate();
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [adapterReady, setAdapterReady] = useState(false);
const [layout, setLayout] = useState<"force" | "tree" | "grid">("force");
const [search, setSearch] = useState("");
// Stable adapter instance
const adapter = useMemo(() => new InMemoryStorageAdapter(), []);
const graphId = `vault-${vaultPath || "default"}`;
// 1. Load graph from backend
useEffect(() => {
if (!vaultPath) return;
buildGraph(vaultPath).then(setGraphData);
buildGraph(vaultPath).then(setGraphData).catch(err => {
console.error("[GraphView] buildGraph failed:", err);
});
}, [vaultPath]);
// 2. Populate adapter with graph data, THEN allow canvas to mount
useEffect(() => {
if (!graphData) return;
const populate = async () => {
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,
graph_id: graphId,
label: n.label,
node_type: "note",
ui_properties: {
x: (Math.random() - 0.5) * 800,
y: (Math.random() - 0.5) * 800,
width: Math.max(120, 80 + radius * 4),
height: 50,
},
data: {
path: n.path,
link_count: n.link_count,
color: colors[clusterId % colors.length],
tags: [],
cluster_id: clusterId,
},
};
});
// 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: {},
}));
// Populate adapter via batch create
if (nodes.length > 0) {
await adapter.createNodes(graphId, nodes);
}
if (edges.length > 0) {
await adapter.createEdges(graphId, edges);
}
console.log(`[GraphView] Populated ${nodes.length} nodes, ${edges.length} edges`);
setAdapterReady(true);
};
populate();
}, [graphData, graphId, adapter]);
const handleNodeClick = useCallback((nodeId: string) => {
if (!graphData) return;
const node = graphData.nodes.find(n => n.id === nodeId);
if (node) navigate(`/note/${encodeURIComponent(node.path)}`);
}, [graphData, navigate]);
const renderNode = useCallback(({ node, isSelected }: any) => (
<NoteGraphNode nodeData={node} isSelected={isSelected} />
), []);
if (!graphData) {
return (
<div className="flex-1 flex items-center justify-center">
<p className="text-[var(--text-muted)] animate-pulse">
Building graph...
</p>
</div>
);
return <div className="graph-loading">Loading graph</div>;
}
if (!adapterReady) {
return <div className="graph-loading">Building graph</div>;
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="graph-header">
<div className="flex items-center gap-3">
<h2 style={{ fontSize: 14, fontWeight: 600, color: "var(--text-primary)" }}>
🔮 Graph View
</h2>
<span className="badge badge-purple">{graphData.nodes.length} notes</span>
<span className="badge badge-muted">{graphData.edges.length} links</span>
</div>
<div style={{ fontSize: 10, color: "var(--text-muted)" }}>
Scroll to zoom · Click to focus · Double-click to open
<CanvasProviderAny adapter={adapter} graphId={graphId}>
<div className="graph-canvas-wrapper">
<div className="graph-toolbar">
<span className="graph-stats">
{graphData.nodes.length} notes · {graphData.edges.length} links
</span>
<LayoutButtons layout={layout} onLayoutChange={setLayout} />
<input
type="text"
placeholder="Search graph…"
value={search}
onChange={e => setSearch(e.target.value)}
className="graph-search-input"
/>
</div>
<Canvas renderNode={renderNode} minZoom={0.05} maxZoom={5}>
<ViewportControls />
<AutoLayout />
</Canvas>
</div>
<div className="flex-1 relative">
<ForceGraph
graphData={graphData}
onNodeClick={(path) => navigate(`/note/${encodeURIComponent(path)}`)}
/>
</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>
);
}
/* ── Force-Directed Graph with Semantic Zoom ──────────────── */
function ForceGraph({
graphData,
onNodeClick,
}: {
graphData: GraphData;
onNodeClick: (path: string) => void;
}) {
const { vaultPath } = useVault();
const canvasRef = useRef<HTMLCanvasElement>(null);
const nodesRef = useRef<SimNode[]>([]);
const edgesRef = useRef<SimEdge[]>([]);
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
const animRef = useRef<number>(0);
const stateRef = useRef({
zoom: 1,
panX: 0,
panY: 0,
W: 800,
H: 600,
isDragging: false,
dragNode: null as SimNode | null,
lastMouse: { x: 0, y: 0 },
mouseDownPos: { x: 0, y: 0 },
hasDragged: false,
hoveredNode: null as SimNode | null,
alpha: 1.0,
});
// Initialize nodes
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement!;
const W = parent.clientWidth;
const H = parent.clientHeight;
const s = stateRef.current;
s.W = W;
s.H = H;
const cx = W / 2;
const cy = H / 2;
const circleR = Math.min(W, H) * 0.3;
const n = graphData.nodes.length;
const nodes: SimNode[] = graphData.nodes.map((node, i) => ({
id: node.id,
label: node.label,
path: node.path,
linkCount: node.link_count,
x: cx + circleR * Math.cos((2 * Math.PI * i) / Math.max(n, 1)),
y: cy + circleR * Math.sin((2 * Math.PI * i) / Math.max(n, 1)),
vx: 0,
vy: 0,
baseRadius: Math.max(10, Math.min(28, 10 + node.link_count * 4)),
color: NODE_COLORS[i % NODE_COLORS.length],
pinned: false,
preview: "",
}));
nodesRef.current = nodes;
edgesRef.current = graphData.edges;
nodeMapRef.current = new Map(nodes.map((n) => [n.id, n]));
s.alpha = 1.0;
// HiDPI
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + "px";
canvas.style.height = H + "px";
// Load note previews
if (vaultPath) {
for (const node of nodes) {
readNote(vaultPath, node.path).then((content) => {
// Strip markdown, take first 120 chars
const cleaned = content
.replace(/^#+ .*/gm, "") // remove headings
.replace(/\[\[([^\]]+)\]\]/g, "$1") // unwrap wikilinks
.replace(/[*_~`]/g, "") // strip formatting
.trim();
const lines = cleaned.split("\n").filter(l => l.trim()).slice(0, 4);
node.preview = lines.join("\n").substring(0, 150);
}).catch(() => { /* ignore missing files */ });
}
}
}, [graphData, vaultPath]);
// Animation loop
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")!;
function loop() {
simulate();
draw(ctx);
animRef.current = requestAnimationFrame(loop);
}
animRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(animRef.current);
}, [graphData]);
// ── Force simulation ──
function simulate() {
const s = stateRef.current;
const nodes = nodesRef.current;
const edges = edgesRef.current;
const nodeMap = nodeMapRef.current;
if (s.alpha < 0.001) return;
const cx = s.W / 2;
const cy = s.H / 2;
// Center gravity
for (const node of nodes) {
if (node.pinned) continue;
node.vx += (cx - node.x) * 0.002 * s.alpha;
node.vy += (cy - node.y) * 0.002 * s.alpha;
}
// 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);
if (dist < 1) {
dx = (Math.random() - 0.5) * 4;
dy = (Math.random() - 0.5) * 4;
dist = Math.sqrt(dx * dx + dy * dy);
}
const strength = -1600 / (dist * dist + 200);
const fx = (dx / dist) * strength * s.alpha;
const fy = (dy / dist) * strength * s.alpha;
if (!a.pinned) { a.vx -= fx; a.vy -= fy; }
if (!b.pinned) { b.vx += fx; b.vy += fy; }
// Collision — generous spacing so cards don't overlap
const minD = a.baseRadius + b.baseRadius + 100;
if (dist < minD) {
const push = (minD - dist) * 0.3;
const px = (dx / dist) * push;
const py = (dy / dist) * push;
if (!a.pinned) { a.x -= px; a.y -= py; }
if (!b.pinned) { b.x += px; b.y += py; }
}
}
}
// Link springs
for (const edge of edges) {
const src = nodeMap.get(edge.source);
const tgt = nodeMap.get(edge.target);
if (!src || !tgt) continue;
let dx = tgt.x - src.x, dy = tgt.y - src.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - 280) * 0.006 * s.alpha;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
if (!src.pinned) { src.vx += fx; src.vy += fy; }
if (!tgt.pinned) { tgt.vx -= fx; tgt.vy -= fy; }
}
// Velocity + boundary
for (const node of nodes) {
if (node.pinned) { node.vx = 0; node.vy = 0; continue; }
node.vx *= 0.82;
node.vy *= 0.82;
node.x += node.vx;
node.y += node.vy;
const m = 50;
if (node.x < m) node.vx += (m - node.x) * 0.08;
if (node.x > s.W - m) node.vx += (s.W - m - node.x) * 0.08;
if (node.y < m) node.vy += (m - node.y) * 0.08;
if (node.y > s.H - m) node.vy += (s.H - m - node.y) * 0.08;
}
s.alpha *= 0.995;
}
// ── Render with semantic zoom ──
function draw(ctx: CanvasRenderingContext2D) {
const s = stateRef.current;
const nodes = nodesRef.current;
const edges = edgesRef.current;
const nodeMap = nodeMapRef.current;
const { W, H, zoom, panX, panY, hoveredNode } = s;
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.save();
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
// Semantic zoom factor: 0 = circle mode, 1 = full card mode
// Transition: starts at 1.2x, full card at 2.5x
const cardT = clamp01((zoom - 1.2) / 1.3);
const CARD_W = 160;
const CARD_H = lerp(36, 90, cardT);
// ── Edges ──
for (const edge of edges) {
const src = nodeMap.get(edge.source);
const tgt = nodeMap.get(edge.target);
if (!src || !tgt) continue;
const lit = hoveredNode === src || hoveredNode === tgt;
ctx.beginPath();
ctx.moveTo(src.x, src.y);
ctx.lineTo(tgt.x, tgt.y);
ctx.strokeStyle = lit ? "rgba(139,92,246,0.5)" : "rgba(255,255,255,0.06)";
ctx.lineWidth = lit ? 2 : 1;
ctx.stroke();
}
// ── Nodes (circle→card morph) ──
for (const node of nodes) {
const hovered = hoveredNode === node;
const connected = hoveredNode && edges.some(
e => (nodeMap.get(e.source) === hoveredNode && nodeMap.get(e.target) === node) ||
(nodeMap.get(e.target) === hoveredNode && nodeMap.get(e.source) === node)
);
const dimmed = hoveredNode != null && !hovered && !connected;
const r = node.baseRadius;
if (cardT < 0.05) {
// ── Pure circle mode ──
drawCircleNode(ctx, node, r, hovered, dimmed);
} else if (cardT > 0.95) {
// ── Pure card mode ──
drawCardNode(ctx, node, CARD_W, CARD_H, hovered, dimmed);
} else {
// ── Morphing transition ──
drawMorphNode(ctx, node, r, CARD_W, CARD_H, cardT, hovered, dimmed);
}
}
ctx.restore();
}
// ── Circle rendering (low zoom) ──
function drawCircleNode(
ctx: CanvasRenderingContext2D, node: SimNode, r: number,
hovered: boolean, dimmed: boolean
) {
// Glow
if (hovered) {
const g = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 14);
g.addColorStop(0, node.color + "40");
g.addColorStop(1, node.color + "00");
ctx.beginPath();
ctx.arc(node.x, node.y, r + 14, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
}
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = dimmed ? node.color + "30" : hovered ? node.color : node.color + "BB";
ctx.fill();
ctx.strokeStyle = hovered ? "#fff" : dimmed ? node.color + "20" : node.color + "60";
ctx.lineWidth = hovered ? 2.5 : 1.5;
ctx.stroke();
// Label
const fs = hovered ? 12 : 10;
ctx.font = `${hovered ? "600" : "400"} ${fs}px Inter, sans-serif`;
ctx.fillStyle = dimmed ? "rgba(232,232,240,0.15)" : hovered ? "#fff" : "rgba(232,232,240,0.7)";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(node.label, node.x, node.y + r + 6, 120);
// Badge
if (node.linkCount > 0 && !dimmed) {
const bx = node.x + r * 0.7, by = node.y - r * 0.7;
ctx.beginPath();
ctx.arc(bx, by, 7, 0, Math.PI * 2);
ctx.fillStyle = "#8b5cf6";
ctx.fill();
ctx.font = "600 7px Inter";
ctx.fillStyle = "#fff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(node.linkCount), bx, by);
}
}
// ── Card rendering (high zoom) ──
function drawCardNode(
ctx: CanvasRenderingContext2D, node: SimNode,
w: number, h: number, hovered: boolean, dimmed: boolean
) {
const x = node.x - w / 2;
const y = node.y - h / 2;
const borderR = 12;
// Shadow
if (hovered) {
ctx.shadowColor = node.color + "60";
ctx.shadowBlur = 20;
ctx.shadowOffsetY = 4;
}
// Card background
ctx.beginPath();
ctx.roundRect(x, y, w, h, borderR);
ctx.fillStyle = dimmed ? "rgba(18,18,26,0.4)" : "rgba(18,18,26,0.92)";
ctx.fill();
ctx.strokeStyle = hovered ? node.color : dimmed ? "rgba(42,42,62,0.3)" : "rgba(42,42,62,0.8)";
ctx.lineWidth = hovered ? 2 : 1;
ctx.stroke();
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Color accent bar at top
ctx.beginPath();
ctx.roundRect(x, y, w, 4, [borderR, borderR, 0, 0]);
ctx.fillStyle = dimmed ? node.color + "30" : node.color;
ctx.fill();
// Title
ctx.font = `600 9px Inter, sans-serif`;
ctx.fillStyle = dimmed ? "rgba(232,232,240,0.2)" : hovered ? "#fff" : "rgba(232,232,240,0.9)";
ctx.textAlign = "left";
ctx.textBaseline = "top";
const title = truncate(node.label, 22);
ctx.fillText(title, x + 8, y + 10);
// Link badge in title area
if (node.linkCount > 0) {
const badgeText = `${node.linkCount} link${node.linkCount > 1 ? "s" : ""}`;
ctx.font = "500 7px Inter, sans-serif";
ctx.fillStyle = dimmed ? node.color + "30" : node.color + "AA";
ctx.textAlign = "right";
ctx.fillText(badgeText, x + w - 8, y + 12);
}
// Preview text
if (node.preview && h > 50) {
ctx.font = "400 7px Inter, sans-serif";
ctx.fillStyle = dimmed ? "rgba(160,160,184,0.15)" : "rgba(160,160,184,0.8)";
ctx.textAlign = "left";
const lines = wrapText(ctx, node.preview, w - 20);
const maxLines = Math.floor((h - 30) / 12);
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
ctx.fillText(lines[i], x + 10, y + 26 + i * 12, w - 20);
}
}
}
// ── Morphing transition (circle→card) ──
function drawMorphNode(
ctx: CanvasRenderingContext2D, node: SimNode, r: number,
cardW: number, cardH: number, t: number,
hovered: boolean, dimmed: boolean
) {
// Interpolate dimensions
const circleSize = r * 2;
const w = lerp(circleSize, cardW, t);
const h = lerp(circleSize, cardH, t);
const borderR = lerp(r, 12, t);
const x = node.x - w / 2;
const y = node.y - h / 2;
// Background
ctx.beginPath();
ctx.roundRect(x, y, w, h, borderR);
const bgAlpha = lerp(0, 0.92, t);
ctx.fillStyle = dimmed
? `rgba(18,18,26,${bgAlpha * 0.4})`
: `rgba(18,18,26,${bgAlpha})`;
ctx.fill();
// Border / node fill blend
ctx.strokeStyle = hovered ? node.color : dimmed ? node.color + "20" : node.color + "60";
ctx.lineWidth = hovered ? 2 : 1.5;
ctx.stroke();
// Colored fill (fades as card grows)
const fillAlpha = lerp(0.8, 0, t);
if (fillAlpha > 0.01) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, borderR);
ctx.fillStyle = dimmed
? node.color + hex2(fillAlpha * 0.3)
: node.color + hex2(fillAlpha);
ctx.fill();
}
// Color accent bar (fades in)
if (t > 0.3) {
const barAlpha = clamp01((t - 0.3) / 0.3);
ctx.beginPath();
ctx.roundRect(x, y, w, 3 + t, [borderR, borderR, 0, 0]);
ctx.fillStyle = dimmed ? node.color + hex2(barAlpha * 0.3) : node.color + hex2(barAlpha);
ctx.fill();
}
// Title (always visible)
const titleAlpha = dimmed ? 0.2 : hovered ? 1 : 0.85;
const titleSize = lerp(10, 12, t);
ctx.font = `${hovered ? "600" : "500"} ${titleSize}px Inter, sans-serif`;
ctx.fillStyle = `rgba(232,232,240,${titleAlpha})`;
ctx.textAlign = t > 0.5 ? "left" : "center";
ctx.textBaseline = "top";
const titleX = t > 0.5 ? x + 10 : node.x;
const titleY = t > 0.5 ? y + 10 : node.y + h / 2 + 6;
ctx.fillText(truncate(node.label, 22), titleX, titleY, w - 20);
// Preview text (fades in)
if (t > 0.6 && node.preview && h > 40) {
const previewAlpha = clamp01((t - 0.6) / 0.3);
ctx.font = "400 7px Inter, sans-serif";
ctx.fillStyle = `rgba(160,160,184,${previewAlpha * (dimmed ? 0.15 : 0.7)})`;
ctx.textAlign = "left";
const lines = wrapText(ctx, node.preview, w - 24);
const maxLines = Math.floor((h - 32) / 14);
for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
ctx.fillText(lines[i], x + 12, y + 28 + i * 14, w - 24);
}
}
}
// ── Mouse helpers ──
function screenToWorld(sx: number, sy: number) {
const s = stateRef.current;
return { x: (sx - s.panX) / s.zoom, y: (sy - s.panY) / s.zoom };
}
function findNodeAt(wx: number, wy: number): SimNode | null {
const s = stateRef.current;
const nodes = nodesRef.current;
const cardT = clamp01((s.zoom - 1.2) / 1.3);
const CARD_W = 180;
const CARD_H = lerp(36, 90, cardT);
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
if (cardT > 0.3) {
// Card hit test (rectangular)
const w = lerp(n.baseRadius * 2, CARD_W, cardT);
const h = lerp(n.baseRadius * 2, CARD_H, cardT);
const pad = 8;
if (
wx >= n.x - w / 2 - pad && wx <= n.x + w / 2 + pad &&
wy >= n.y - h / 2 - pad && wy <= n.y + h / 2 + pad
) return n;
} else {
// Circle hit test
const dx = wx - n.x, dy = wy - n.y;
const hitR = n.baseRadius + 20;
if (dx * dx + dy * dy <= hitR * hitR) return n;
// Label area
const labelW = n.label.length * 4 + 10;
if (Math.abs(dx) <= labelW && dy >= n.baseRadius && dy <= n.baseRadius + 22) return n;
}
}
return null;
}
// ── Event handlers ──
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const s = stateRef.current;
function onDown(e: MouseEvent) {
const wp = screenToWorld(e.offsetX, e.offsetY);
const node = findNodeAt(wp.x, wp.y);
s.mouseDownPos = { x: e.offsetX, y: e.offsetY };
s.hasDragged = false;
if (node) {
s.dragNode = node;
node.pinned = true;
s.alpha = Math.max(s.alpha, 0.3);
} else {
s.isDragging = true;
}
s.lastMouse = { x: e.offsetX, y: e.offsetY };
}
function onMove(e: MouseEvent) {
const dx = e.offsetX - s.mouseDownPos.x;
const dy = e.offsetY - s.mouseDownPos.y;
if (dx * dx + dy * dy > 16) s.hasDragged = true;
const wp = screenToWorld(e.offsetX, e.offsetY);
if (s.dragNode) {
s.dragNode.x = wp.x;
s.dragNode.y = wp.y;
s.dragNode.vx = 0;
s.dragNode.vy = 0;
s.alpha = Math.max(s.alpha, 0.08);
} else if (s.isDragging) {
s.panX += e.offsetX - s.lastMouse.x;
s.panY += e.offsetY - s.lastMouse.y;
}
s.lastMouse = { x: e.offsetX, y: e.offsetY };
s.hoveredNode = findNodeAt(wp.x, wp.y);
canvas!.style.cursor = s.hoveredNode ? "pointer" : s.isDragging ? "grabbing" : "grab";
}
function onUp() {
if (s.dragNode) {
if (!s.hasDragged) {
// Single click → zoom into the node
const node = s.dragNode;
const targetZoom = Math.min(s.zoom * 2, 12);
const cx = s.W / 2;
const cy = s.H / 2;
// Animate zoom to center on this node
const startZoom = s.zoom;
const startPanX = s.panX;
const startPanY = s.panY;
const endPanX = cx - node.x * targetZoom;
const endPanY = cy - node.y * targetZoom;
const duration = 400;
const t0 = performance.now();
function animateZoom(now: number) {
const elapsed = now - t0;
const p = Math.min(elapsed / duration, 1);
// Ease out cubic
const ease = 1 - Math.pow(1 - p, 3);
s.zoom = startZoom + (targetZoom - startZoom) * ease;
s.panX = startPanX + (endPanX - startPanX) * ease;
s.panY = startPanY + (endPanY - startPanY) * ease;
if (p < 1) requestAnimationFrame(animateZoom);
}
requestAnimationFrame(animateZoom);
}
s.dragNode.pinned = false;
s.dragNode = null;
}
s.isDragging = false;
}
function onDblClick(e: MouseEvent) {
const wp = screenToWorld(e.offsetX, e.offsetY);
const node = findNodeAt(wp.x, wp.y);
if (node) onNodeClick(node.path);
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.96 : 1.04;
s.panX = e.offsetX - (e.offsetX - s.panX) * factor;
s.panY = e.offsetY - (e.offsetY - s.panY) * factor;
s.zoom = Math.max(0.3, Math.min(12, s.zoom * factor));
}
canvas.addEventListener("mousedown", onDown);
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mouseup", onUp);
canvas.addEventListener("mouseleave", onUp);
canvas.addEventListener("dblclick", onDblClick);
canvas.addEventListener("wheel", onWheel, { passive: false });
return () => {
canvas.removeEventListener("mousedown", onDown);
canvas.removeEventListener("mousemove", onMove);
canvas.removeEventListener("mouseup", onUp);
canvas.removeEventListener("mouseleave", onUp);
canvas.removeEventListener("dblclick", onDblClick);
canvas.removeEventListener("wheel", onWheel);
};
}, [graphData, onNodeClick]);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full"
style={{
background: "radial-gradient(ellipse at center, #1a1a2e 0%, #0a0a0f 70%)",
}}
/>
);
}
/* ── Utility functions ──────────────────────────────────────── */
function clamp01(v: number) { return Math.max(0, Math.min(1, v)); }
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
function hex2(alpha: number): string {
return Math.round(clamp01(alpha) * 255).toString(16).padStart(2, "0");
}
function truncate(s: string, max: number): string {
return s.length > max ? s.substring(0, max - 1) + "…" : s;
}
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let current = "";
for (const word of words) {
const test = current ? current + " " + word : word;
if (ctx.measureText(test).width > maxWidth && current) {
lines.push(current);
current = word;
} else {
current = test;
}
}
if (current) lines.push(current);
return lines;
}

View file

@ -0,0 +1,138 @@
import { useState, useEffect, useCallback } from "react";
import { useVault } from "../App";
import { listSnapshots, readSnapshot, saveSnapshot, type SnapshotInfo } from "../lib/commands";
/**
* HistoryPanel Timeline of note snapshots with inline diff viewer.
*/
export function HistoryPanel({ onClose }: { onClose: () => void }) {
const { vaultPath, currentNote, noteContent } = useVault();
const [snapshots, setSnapshots] = useState<SnapshotInfo[]>([]);
const [selectedContent, setSelectedContent] = useState<string | null>(null);
const [selectedName, setSelectedName] = useState<string>("");
const [showDiff, setShowDiff] = useState(false);
const loadSnapshots = useCallback(async () => {
if (!vaultPath || !currentNote) return;
try {
const snaps = await listSnapshots(vaultPath, currentNote);
setSnapshots(snaps);
} catch {
setSnapshots([]);
}
}, [vaultPath, currentNote]);
useEffect(() => { loadSnapshots(); }, [loadSnapshots]);
const handleView = async (snap: SnapshotInfo) => {
if (!vaultPath || !currentNote) return;
try {
const content = await readSnapshot(vaultPath, currentNote, snap.filename);
setSelectedContent(content);
setSelectedName(snap.timestamp);
setShowDiff(false);
} catch (e) {
console.error("Failed to read snapshot:", e);
}
};
const handleSnapshot = async () => {
if (!vaultPath || !currentNote) return;
await saveSnapshot(vaultPath, currentNote);
loadSnapshots();
};
const formatTimestamp = (ts: string) => {
// 20260308_203015 → Mar 8, 20:30
const y = ts.slice(0, 4), mo = ts.slice(4, 6), d = ts.slice(6, 8);
const h = ts.slice(9, 11), mi = ts.slice(11, 13);
const date = new Date(+y, +mo - 1, +d, +h, +mi);
return date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
};
// Simple diff: lines added/removed
const diffLines = () => {
if (!selectedContent) return [];
const oldLines = selectedContent.split("\n");
const newLines = noteContent.split("\n");
const result: { type: "same" | "add" | "remove"; text: string }[] = [];
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const old = oldLines[i];
const cur = newLines[i];
if (old === cur) {
result.push({ type: "same", text: old || "" });
} else {
if (old !== undefined) result.push({ type: "remove", text: old });
if (cur !== undefined) result.push({ type: "add", text: cur });
}
}
return result;
};
return (
<div className="history-panel">
<div className="history-header">
<h3 className="history-title">🕐 History</h3>
<div className="history-actions">
<button className="history-snap-btn" onClick={handleSnapshot}>📸 Snapshot</button>
<button className="history-close" onClick={onClose}></button>
</div>
</div>
<div className="history-body">
{/* Timeline */}
<div className="history-timeline">
{snapshots.length === 0 && (
<div className="history-empty">No snapshots yet</div>
)}
{snapshots.map(snap => (
<button
key={snap.filename}
className={`history-item ${selectedName === snap.timestamp ? "active" : ""}`}
onClick={() => handleView(snap)}
>
<span className="history-dot" />
<div className="history-item-info">
<span className="history-item-time">{formatTimestamp(snap.timestamp)}</span>
<span className="history-item-size">{(snap.size / 1024).toFixed(1)} KB</span>
</div>
</button>
))}
</div>
{/* Viewer */}
{selectedContent !== null && (
<div className="history-viewer">
<div className="history-viewer-header">
<span>{formatTimestamp(selectedName)}</span>
<button
className={`history-diff-toggle ${showDiff ? "active" : ""}`}
onClick={() => setShowDiff(!showDiff)}
>
{showDiff ? "View snapshot" : "Show diff"}
</button>
</div>
<div className="history-viewer-content">
{showDiff ? (
<div className="history-diff">
{diffLines().map((line, i) => (
<div key={i} className={`diff-line diff-${line.type}`}>
<span className="diff-marker">
{line.type === "add" ? "+" : line.type === "remove" ? "" : " "}
</span>
{line.text}
</div>
))}
</div>
) : (
<pre className="history-snapshot-text">{selectedContent}</pre>
)}
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,106 @@
import { useState, useCallback } from "react";
import { useVault } from "../App";
import { exportVaultZip, importFolder, exportSite } from "../lib/commands";
/**
* ImportExport Import notes from folders, export vault as ZIP, publish as site.
*/
export function ImportExport({ onClose }: { onClose: () => void }) {
const { vaultPath, refreshNotes } = useVault();
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
const handleExport = useCallback(async () => {
if (!vaultPath) return;
setLoading(true);
try {
const ts = new Date().toISOString().slice(0, 10);
const outputPath = `${vaultPath}/../vault-export-${ts}.zip`;
const result = await exportVaultZip(vaultPath, outputPath);
setStatus(`${result}`);
} catch (e: any) {
setStatus(`❌ Export failed: ${e}`);
}
setLoading(false);
}, [vaultPath]);
const handleImport = useCallback(async () => {
if (!vaultPath) return;
const sourcePath = prompt("Path to folder containing .md files:");
if (!sourcePath?.trim()) return;
setLoading(true);
try {
const count = await importFolder(vaultPath, sourcePath.trim());
setStatus(`✅ Imported ${count} note${count !== 1 ? "s" : ""}`);
refreshNotes();
} catch (e: any) {
setStatus(`❌ Import failed: ${e}`);
}
setLoading(false);
}, [vaultPath, refreshNotes]);
const handlePublishSite = useCallback(async () => {
if (!vaultPath) return;
const notePathsInput = prompt("Note paths to publish (comma-separated, e.g. note1.md,folder/note2.md):");
if (!notePathsInput?.trim()) return;
const outputDir = prompt("Output directory for the site:", `${vaultPath}/../published-site`);
if (!outputDir?.trim()) return;
setLoading(true);
try {
const notePaths = notePathsInput.split(",").map(p => p.trim()).filter(Boolean);
const result = await exportSite(vaultPath, notePaths, outputDir.trim());
setStatus(`${result}`);
} catch (e: any) {
setStatus(`❌ Publish failed: ${e}`);
}
setLoading(false);
}, [vaultPath]);
return (
<div className="ie-overlay" onClick={onClose}>
<div className="ie-modal" onClick={e => e.stopPropagation()}>
<div className="ie-header">
<h3 className="ie-title">📦 Import / Export</h3>
<button className="ie-close" onClick={onClose}></button>
</div>
<div className="ie-body">
<div className="ie-section">
<h4 className="ie-section-title">Export Vault</h4>
<p className="ie-desc">Create a ZIP archive of all notes and attachments.</p>
<button className="ie-action-btn" onClick={handleExport} disabled={loading}>
📤 Export as ZIP
</button>
</div>
<div className="ie-divider" />
<div className="ie-section">
<h4 className="ie-section-title">Import Notes</h4>
<p className="ie-desc">
Import <code>.md</code> files from an Obsidian vault, Notion export, or any folder.
</p>
<button className="ie-action-btn" onClick={handleImport} disabled={loading}>
📥 Import from Folder
</button>
</div>
<div className="ie-divider" />
<div className="ie-section">
<h4 className="ie-section-title">Publish as Site</h4>
<p className="ie-desc">
Export selected notes as a browsable HTML micro-site with resolved wikilinks.
</p>
<button className="ie-action-btn" onClick={handlePublishSite} disabled={loading}>
🌐 Publish Site
</button>
</div>
{status && <div className="ie-status">{status}</div>}
</div>
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,115 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { listTasks, toggleTask, type TaskItem } from "../lib/commands";
type Column = "todo" | "in-progress" | "done";
const COLUMNS: { id: Column; label: string; icon: string }[] = [
{ id: "todo", label: "To Do", icon: "☐" },
{ id: "in-progress", label: "In Progress", icon: "◐" },
{ id: "done", label: "Done", icon: "✓" },
];
/**
* KanbanView Visual board extracted from `- [ ]` / `- [/]` / `- [x]` items across vault.
*/
export function KanbanView() {
const { vaultPath, refreshNotes } = useVault();
const navigate = useNavigate();
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(true);
const [dragItem, setDragItem] = useState<TaskItem | null>(null);
const loadTasks = useCallback(async () => {
if (!vaultPath) return;
setLoading(true);
try {
const items = await listTasks(vaultPath);
setTasks(items);
} catch {
setTasks([]);
}
setLoading(false);
}, [vaultPath]);
useEffect(() => { loadTasks(); }, [loadTasks]);
const handleDrop = async (column: Column) => {
if (!dragItem || dragItem.state === column) return;
try {
await toggleTask(vaultPath, dragItem.source_path, dragItem.line_number, column);
setTasks(prev => prev.map(t =>
t.source_path === dragItem.source_path && t.line_number === dragItem.line_number
? { ...t, state: column }
: t
));
refreshNotes();
} catch (e) {
console.error("Toggle failed:", e);
}
setDragItem(null);
};
const openSource = (task: TaskItem) => {
navigate(`/note/${encodeURIComponent(task.source_path)}`);
};
if (loading) {
return (
<div className="kanban-view">
<div className="kanban-loading">Loading tasks</div>
</div>
);
}
return (
<div className="kanban-view">
<div className="kanban-header">
<h2 className="kanban-title">📋 Task Board</h2>
<span className="kanban-count">{tasks.length} tasks</span>
<button className="kanban-refresh" onClick={loadTasks}></button>
</div>
<div className="kanban-columns">
{COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.state === col.id);
return (
<div
key={col.id}
className={`kanban-column ${dragItem ? "drop-ready" : ""}`}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(col.id)}
>
<div className="kanban-column-header">
<span className="kanban-column-icon">{col.icon}</span>
<span className="kanban-column-label">{col.label}</span>
<span className="badge badge-muted">{colTasks.length}</span>
</div>
<div className="kanban-column-body">
{colTasks.map((task, i) => (
<div
key={`${task.source_path}-${task.line_number}-${i}`}
className="kanban-card"
draggable
onDragStart={() => setDragItem(task)}
onDragEnd={() => setDragItem(null)}
>
<div className="kanban-card-text">{task.text}</div>
<button
className="kanban-card-source"
onClick={() => openSource(task)}
>
{task.source_path.replace(".md", "")}
</button>
</div>
))}
{colTasks.length === 0 && (
<div className="kanban-empty">No tasks</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,147 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useVault } from "../App";
import { readNotePreview, buildGraph, type GraphData } from "../lib/commands";
interface PreviewState {
visible: boolean;
noteName: string;
notePath: string | null;
content: string;
linkCount: number;
x: number;
y: number;
}
/**
* LinkPreview Floating card that appears when hovering over wikilinks.
* Shows the linked note's title, preview text, and link count.
* Must be rendered inside VaultContext.
*/
export function LinkPreview() {
const { vaultPath, notes } = useVault();
const [preview, setPreview] = useState<PreviewState>({
visible: false, noteName: "", notePath: null, content: "", linkCount: 0, x: 0, y: 0,
});
const hoverTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const graphCacheRef = useRef<GraphData | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
// Cache graph data for link counts
useEffect(() => {
if (!vaultPath) return;
buildGraph(vaultPath).then(data => { graphCacheRef.current = data; }).catch(() => { });
}, [vaultPath, notes]);
const showPreview = useCallback(async (target: string, rect: DOMRect) => {
// Find the note path from name
const allPaths = flattenNotes(notes);
const match = allPaths.find(
p => p.replace(/\.md$/, "").split("/").pop()?.toLowerCase() === target.toLowerCase()
);
if (!match) {
setPreview(p => ({ ...p, visible: false }));
return;
}
try {
const content = await readNotePreview(vaultPath, match, 200);
const graph = graphCacheRef.current;
const nodeData = graph?.nodes.find(n => n.path === match);
const linkCount = nodeData?.link_count || 0;
setPreview({
visible: true,
noteName: target,
notePath: match,
content,
linkCount,
x: rect.left + rect.width / 2,
y: rect.bottom + 8,
});
} catch {
setPreview(p => ({ ...p, visible: false }));
}
}, [vaultPath, notes]);
// Global hover listeners for wikilink tokens
useEffect(() => {
const handleMouseEnter = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("wikilink-token") && !target.classList.contains("wikilink")) return;
const linkTarget = target.dataset.target;
if (!linkTarget) return;
clearTimeout(hideTimerRef.current);
hoverTimerRef.current = setTimeout(() => {
const rect = target.getBoundingClientRect();
showPreview(linkTarget, rect);
}, 350);
};
const handleMouseLeave = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("wikilink-token") && !target.classList.contains("wikilink")) return;
clearTimeout(hoverTimerRef.current);
hideTimerRef.current = setTimeout(() => {
setPreview(p => ({ ...p, visible: false }));
}, 200);
};
document.addEventListener("mouseenter", handleMouseEnter, true);
document.addEventListener("mouseleave", handleMouseLeave, true);
return () => {
document.removeEventListener("mouseenter", handleMouseEnter, true);
document.removeEventListener("mouseleave", handleMouseLeave, true);
clearTimeout(hoverTimerRef.current);
clearTimeout(hideTimerRef.current);
};
}, [showPreview]);
if (!preview.visible) return null;
return (
<div
ref={cardRef}
className="link-preview-card"
style={{
left: Math.max(16, Math.min(preview.x - 140, window.innerWidth - 296)),
top: Math.min(preview.y, window.innerHeight - 160),
}}
onMouseEnter={() => clearTimeout(hideTimerRef.current)}
onMouseLeave={() => {
hideTimerRef.current = setTimeout(() => {
setPreview(p => ({ ...p, visible: false }));
}, 150);
}}
>
<div className="link-preview-header">
<span className="link-preview-icon">📄</span>
<span className="link-preview-title">{preview.noteName}</span>
{preview.linkCount > 0 && (
<span className="badge badge-purple" style={{ fontSize: 9, padding: "1px 5px" }}>
{preview.linkCount} link{preview.linkCount > 1 ? "s" : ""}
</span>
)}
</div>
<div className="link-preview-content">
{preview.content || "Empty note"}
</div>
</div>
);
}
function flattenNotes(entries: { path: string; is_dir: boolean; children?: any[] }[]): string[] {
const paths: string[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
paths.push(...flattenNotes(entry.children));
} else if (!entry.is_dir) {
paths.push(entry.path);
}
}
return paths;
}

View file

@ -0,0 +1,69 @@
import { useState } from "react";
/**
* LockScreen Password prompt overlay for encrypted notes.
*/
export function LockScreen({
onUnlock,
onCancel,
error,
}: {
onUnlock: (password: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (password.trim()) onUnlock(password);
};
return (
<div className="lock-screen">
<div className="lock-card">
<div className="lock-icon">🔒</div>
<h3 className="lock-title">Encrypted Note</h3>
<p className="lock-desc">Enter password to decrypt</p>
<form onSubmit={handleSubmit} className="lock-form">
<input
type="password"
className="lock-input"
placeholder="Password…"
value={password}
onChange={e => setPassword(e.target.value)}
autoFocus
/>
{error && <div className="lock-error">{error}</div>}
<div className="lock-actions">
<button type="button" className="lock-cancel" onClick={onCancel}>Cancel</button>
<button type="submit" className="lock-submit" disabled={!password.trim()}>Decrypt</button>
</div>
</form>
</div>
</div>
);
}
/**
* LockButton Toggle encryption in editor header.
*/
export function LockButton({
isLocked,
onLock,
onUnlock,
}: {
isLocked: boolean;
onLock: () => void;
onUnlock: () => void;
}) {
return (
<button
className={`lock-btn ${isLocked ? "locked" : ""}`}
onClick={isLocked ? onUnlock : onLock}
title={isLocked ? "Decrypt note" : "Encrypt note"}
>
{isLocked ? "🔒" : "🔓"}
</button>
);
}

View file

@ -0,0 +1,133 @@
import { useEffect, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useVault } from "../App";
import { extractWikilinks } from "../lib/wikilinks";
/**
* MiniGraph Small canvas showing current note's 1-hop link neighborhood.
*/
export function MiniGraph() {
const { currentNote, noteContent, notes } = useVault();
const navigate = useNavigate();
const canvasRef = useRef<HTMLCanvasElement>(null);
// Build local graph data
const graphData = useMemo(() => {
if (!currentNote || !noteContent) return { nodes: [], edges: [] };
const noteName = currentNote.replace(/\.md$/, "").split("/").pop() || "";
const links = extractWikilinks(noteContent);
const nodeSet = new Set<string>([noteName]);
const edges: { from: string; to: string }[] = [];
// Outgoing links
for (const link of links) {
nodeSet.add(link.target);
edges.push({ from: noteName, to: link.target });
}
// Backlinks: scan all notes for links to current
const flatNotes = flattenNotes(notes);
for (const n of flatNotes) {
const nName = n.replace(/\.md$/, "").split("/").pop() || "";
if (nName !== noteName && !nodeSet.has(nName)) {
// Would need content to check — skip for now, just show outgoing
}
}
const nodesArr = Array.from(nodeSet).map((name, i) => {
const angle = (2 * Math.PI * i) / nodeSet.size;
const r = nodeSet.size > 1 ? 55 : 0;
return {
name,
x: 100 + r * Math.cos(angle),
y: 80 + r * Math.sin(angle),
isCurrent: name === noteName,
};
});
return { nodes: nodesArr, edges };
}, [currentNote, noteContent, notes]);
// Render canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Edges
ctx.strokeStyle = "rgba(139, 92, 246, 0.3)";
ctx.lineWidth = 1;
for (const edge of graphData.edges) {
const from = graphData.nodes.find(n => n.name === edge.from);
const to = graphData.nodes.find(n => n.name === edge.to);
if (from && to) {
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
}
// Nodes
for (const node of graphData.nodes) {
ctx.beginPath();
ctx.arc(node.x, node.y, node.isCurrent ? 6 : 4, 0, 2 * Math.PI);
ctx.fillStyle = node.isCurrent ? "#a78bfa" : "#52525b";
ctx.fill();
// Label
ctx.fillStyle = node.isCurrent ? "#fafafa" : "#a1a1aa";
ctx.font = "9px Inter, sans-serif";
ctx.textAlign = "center";
const label = node.name.length > 12 ? node.name.slice(0, 11) + "…" : node.name;
ctx.fillText(label, node.x, node.y + 14);
}
}, [graphData]);
const handleClick = (e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
for (const node of graphData.nodes) {
const dx = node.x - x;
const dy = node.y - y;
if (dx * dx + dy * dy < 100) {
navigate(`/note/${encodeURIComponent(node.name + ".md")}`);
break;
}
}
};
if (graphData.nodes.length <= 1) return null;
return (
<div className="mini-graph-panel">
<div className="mini-graph-label">Local Graph</div>
<canvas
ref={canvasRef}
width={200}
height={160}
className="mini-graph-canvas"
onClick={handleClick}
/>
</div>
);
}
function flattenNotes(entries: any[]): string[] {
const result: string[] = [];
for (const e of entries) {
if (e.is_dir && e.children) result.push(...flattenNotes(e.children));
else if (!e.is_dir) result.push(e.path);
}
return result;
}

View file

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

View file

@ -0,0 +1,120 @@
import { useState, useEffect, useMemo } from "react";
import { useVault } from "../App";
interface Heading {
level: number;
text: string;
line: number;
}
/**
* OutlinePanel Collapsible heading tree from current note.
*/
export function OutlinePanel({ content, onScrollTo }: {
content: string;
onScrollTo: (line: number) => void;
}) {
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [activeIdx, setActiveIdx] = useState(0);
const headings = useMemo(() => {
const result: Heading[] = [];
content.split("\n").forEach((line, i) => {
const match = line.match(/^(#{1,6})\s+(.+)/);
if (match) {
result.push({
level: match[1].length,
text: match[2].replace(/[*_`\[\]]/g, ""),
line: i + 1,
});
}
});
return result;
}, [content]);
const toggleCollapse = (idx: number) => {
setCollapsed(prev => {
const next = new Set(prev);
next.has(idx) ? next.delete(idx) : next.add(idx);
return next;
});
};
// Determine which headings are visible (not collapsed by parent)
const visibleHeadings = useMemo(() => {
const visible: { heading: Heading; idx: number; hidden: boolean }[] = [];
const collapseStack: number[] = [];
headings.forEach((h, i) => {
// Pop from stack if this heading is at same or higher level
while (collapseStack.length > 0 &&
headings[collapseStack[collapseStack.length - 1]].level >= h.level) {
collapseStack.pop();
}
const hidden = collapseStack.some(ci => collapsed.has(ci));
visible.push({ heading: h, idx: i, hidden });
if (collapsed.has(i)) {
collapseStack.push(i);
}
});
return visible;
}, [headings, collapsed]);
const hasChildren = (idx: number): boolean => {
if (idx >= headings.length - 1) return false;
return headings[idx + 1].level > headings[idx].level;
};
if (headings.length === 0) {
return (
<div className="outline-panel">
<div className="outline-empty">
<p>No headings found</p>
<p className="outline-hint">Add # headings to your note</p>
</div>
</div>
);
}
return (
<div className="outline-panel">
<div className="outline-header">
<span className="outline-title">Outline</span>
<span className="badge badge-purple">{headings.length}</span>
</div>
<div className="outline-tree">
{visibleHeadings.map(({ heading, idx, hidden }) => {
if (hidden) return null;
const indent = (heading.level - 1) * 14;
const canCollapse = hasChildren(idx);
const isCollapsed = collapsed.has(idx);
return (
<div
key={idx}
className={`outline-item ${activeIdx === idx ? "active" : ""}`}
style={{ paddingLeft: 8 + indent }}
onClick={() => { setActiveIdx(idx); onScrollTo(heading.line); }}
>
{canCollapse && (
<button
className="outline-collapse-btn"
onClick={(e) => { e.stopPropagation(); toggleCollapse(idx); }}
>
{isCollapsed ? "▸" : "▾"}
</button>
)}
<span className="outline-heading-marker">
{"#".repeat(heading.level)}
</span>
<span className="outline-heading-text">{heading.text}</span>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
import { useState, useEffect, useCallback } from "react";
import { useVault } from "../App";
import { parseFrontmatter, writeFrontmatter } from "../lib/commands";
/**
* PropertiesPanel Collapsible YAML frontmatter metadata editor.
* Parses `---` fenced YAML, displays key-value pairs, supports inline editing.
*/
export function PropertiesPanel() {
const { vaultPath, currentNote } = useVault();
const [properties, setProperties] = useState<Record<string, string>>({});
const [isOpen, setIsOpen] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
// Load frontmatter when note changes
useEffect(() => {
if (!vaultPath || !currentNote) {
setProperties({});
return;
}
parseFrontmatter(vaultPath, currentNote)
.then(data => setProperties(data as Record<string, string>))
.catch(() => setProperties({}));
}, [vaultPath, currentNote]);
const save = useCallback(async (updated: Record<string, string>) => {
if (!vaultPath || !currentNote) return;
setProperties(updated);
try {
await writeFrontmatter(vaultPath, currentNote, updated);
} catch (e) {
console.error("Failed to save frontmatter:", e);
}
}, [vaultPath, currentNote]);
const handleEditStart = (key: string) => {
setEditingKey(key);
setEditValue(properties[key] || "");
};
const handleEditSave = () => {
if (!editingKey) return;
const updated = { ...properties, [editingKey]: editValue };
save(updated);
setEditingKey(null);
};
const handleDelete = (key: string) => {
const updated = { ...properties };
delete updated[key];
save(updated);
};
const handleAddProperty = () => {
if (!newKey.trim()) return;
const updated = { ...properties, [newKey.trim()]: newValue.trim() };
save(updated);
setNewKey("");
setNewValue("");
};
const hasProperties = Object.keys(properties).length > 0;
if (!currentNote) return null;
return (
<div className="properties-panel">
<button className="properties-toggle" onClick={() => setIsOpen(!isOpen)}>
<span className="properties-chevron" style={{ transform: isOpen ? "rotate(0)" : "rotate(-90deg)" }}></span>
<span className="properties-label">
Properties
{hasProperties && <span className="badge badge-purple" style={{ fontSize: 9, marginLeft: 6 }}>{Object.keys(properties).length}</span>}
</span>
</button>
{isOpen && (
<div className="properties-body">
{Object.entries(properties).map(([key, value]) => (
<div key={key} className="property-row">
<span className="property-key">{key}</span>
{editingKey === key ? (
<input
className="property-input"
value={editValue}
autoFocus
onChange={e => setEditValue(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") handleEditSave();
if (e.key === "Escape") setEditingKey(null);
}}
onBlur={handleEditSave}
/>
) : (
<span className="property-value" onClick={() => handleEditStart(key)}>
{value || <em className="text-muted">empty</em>}
</span>
)}
<button className="property-delete" onClick={() => handleDelete(key)} title="Remove"></button>
</div>
))}
{/* Add new property */}
<div className="property-add">
<input
className="property-input"
placeholder="key"
value={newKey}
onChange={e => setNewKey(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") handleAddProperty(); }}
/>
<input
className="property-input"
placeholder="value"
value={newValue}
onChange={e => setNewValue(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") handleAddProperty(); }}
/>
<button className="property-add-btn" onClick={handleAddProperty}>+</button>
</div>
</div>
)}
</div>
);
}

View file

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

View file

@ -0,0 +1,104 @@
import { useState } from "react";
import { useVault } from "../App";
import { extractToNote, mergeNotes } from "../lib/commands";
/**
* RefactorMenu Context menu for note refactoring operations.
*/
export function RefactorMenu({
visible,
position,
selectedText,
onClose,
}: {
visible: boolean;
position: { top: number; left: number };
selectedText: string;
onClose: () => void;
}) {
const { vaultPath, currentNote, notes, navigateToNote, refreshNotes } = useVault();
const [showMerge, setShowMerge] = useState(false);
const [mergeSearch, setMergeSearch] = useState("");
if (!visible) return null;
const handleExtract = async () => {
const name = prompt("New note name:");
if (!name?.trim() || !currentNote) return;
try {
const newPath = await extractToNote(vaultPath, currentNote, selectedText, name.trim());
refreshNotes();
navigateToNote(name.trim());
onClose();
} catch (e) {
alert(`Extract failed: ${e}`);
}
};
const handleMerge = async (targetPath: string) => {
if (!currentNote) return;
const confirm = window.confirm(`Merge "${currentNote.replace(".md", "")}" INTO "${targetPath.replace(".md", "")}"? Source will be deleted.`);
if (!confirm) return;
try {
await mergeNotes(vaultPath, currentNote, targetPath);
refreshNotes();
navigateToNote(targetPath.replace(".md", ""));
onClose();
} catch (e) {
alert(`Merge failed: ${e}`);
}
};
const flatNotes = flattenPaths(notes).filter(p => p !== currentNote);
const filtered = mergeSearch
? flatNotes.filter(p => p.toLowerCase().includes(mergeSearch.toLowerCase()))
: flatNotes.slice(0, 10);
return (
<div className="refactor-menu" style={{ top: position.top, left: position.left }}>
{!showMerge ? (
<>
{selectedText && (
<button className="refactor-item" onClick={handleExtract}>
<span className="refactor-icon"></span>
Extract to new note
</button>
)}
<button className="refactor-item" onClick={() => setShowMerge(true)}>
<span className="refactor-icon">🔗</span>
Merge into
</button>
</>
) : (
<div className="refactor-merge">
<input
className="refactor-search"
placeholder="Search target note…"
value={mergeSearch}
onChange={e => setMergeSearch(e.target.value)}
autoFocus
/>
<div className="refactor-merge-list">
{filtered.map(p => (
<button key={p} className="refactor-merge-item" onClick={() => handleMerge(p)}>
{p.replace(".md", "")}
</button>
))}
</div>
<button className="refactor-back" onClick={() => { setShowMerge(false); setMergeSearch(""); }}>
Back
</button>
</div>
)}
</div>
);
}
function flattenPaths(entries: any[]): string[] {
const result: string[] = [];
for (const e of entries) {
if (e.is_dir && e.children) result.push(...flattenPaths(e.children));
else if (!e.is_dir) result.push(e.path);
}
return result;
}

View file

@ -0,0 +1,101 @@
import { useState, useCallback } from "react";
import { useVault } from "../App";
import { searchReplaceVault, type ReplaceResult } from "../lib/commands";
/**
* SearchReplace Global find & replace across the vault with preview.
*/
export function SearchReplace({ open, onClose }: { open: boolean; onClose: () => void }) {
const { vaultPath, refreshNotes } = useVault();
const [search, setSearch] = useState("");
const [replace, setReplace] = useState("");
const [results, setResults] = useState<ReplaceResult[]>([]);
const [previewed, setPreviewed] = useState(false);
const [applied, setApplied] = useState(false);
const handlePreview = useCallback(async () => {
if (!search.trim() || !vaultPath) return;
try {
const res = await searchReplaceVault(vaultPath, search, replace, true);
setResults(res);
setPreviewed(true);
setApplied(false);
} catch (e) {
console.error("Search failed:", e);
}
}, [vaultPath, search, replace]);
const handleApply = useCallback(async () => {
if (!search.trim() || !vaultPath) return;
try {
await searchReplaceVault(vaultPath, search, replace, false);
setApplied(true);
refreshNotes();
} catch (e) {
console.error("Replace failed:", e);
}
}, [vaultPath, search, replace, refreshNotes]);
const totalMatches = results.reduce((sum, r) => sum + r.count, 0);
if (!open) return null;
return (
<div className="sr-backdrop" onClick={onClose}>
<div className="sr-modal" onClick={e => e.stopPropagation()}>
<h3 className="sr-title">🔍 Search & Replace</h3>
<div className="sr-inputs">
<div className="sr-field">
<label className="sr-label">Find</label>
<input
className="sr-input"
value={search}
onChange={e => { setSearch(e.target.value); setPreviewed(false); setApplied(false); }}
placeholder="Search text…"
autoFocus
/>
</div>
<div className="sr-field">
<label className="sr-label">Replace</label>
<input
className="sr-input"
value={replace}
onChange={e => { setReplace(e.target.value); setPreviewed(false); setApplied(false); }}
placeholder="Replacement…"
/>
</div>
</div>
<div className="sr-actions">
<button className="sr-btn sr-preview-btn" onClick={handlePreview} disabled={!search.trim()}>
Preview
</button>
{previewed && !applied && (
<button className="sr-btn sr-apply-btn" onClick={handleApply}>
Replace All ({totalMatches} in {results.length} files)
</button>
)}
</div>
{previewed && (
<div className="sr-results">
{results.length === 0 ? (
<div className="sr-empty">No matches found</div>
) : (
<>
{applied && <div className="sr-success"> Replaced {totalMatches} occurrences</div>}
{results.map(r => (
<div key={r.path} className="sr-result-row">
<span className="sr-result-path">{r.path}</span>
<span className="sr-result-count">{r.count} match{r.count !== 1 ? "es" : ""}</span>
</div>
))}
</>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useState, useEffect, useCallback } from "react";
import { useVault } from "../App";
import { saveShortcuts, loadShortcuts } from "../lib/commands";
interface Shortcut {
id: string;
label: string;
keys: string;
defaultKeys: string;
}
const DEFAULT_SHORTCUTS: Shortcut[] = [
{ id: "cmd-palette", label: "Command Palette", keys: "Ctrl+K", defaultKeys: "Ctrl+K" },
{ id: "new-note", label: "New Note", keys: "Ctrl+N", defaultKeys: "Ctrl+N" },
{ id: "save", label: "Save", keys: "Ctrl+S", defaultKeys: "Ctrl+S" },
{ id: "search", label: "Search", keys: "Ctrl+F", defaultKeys: "Ctrl+F" },
{ id: "graph", label: "Graph View", keys: "Ctrl+G", defaultKeys: "Ctrl+G" },
{ id: "daily", label: "Daily Note", keys: "Ctrl+D", defaultKeys: "Ctrl+D" },
{ id: "sidebar", label: "Toggle Sidebar", keys: "Ctrl+B", defaultKeys: "Ctrl+B" },
{ id: "focus", label: "Focus Mode", keys: "Ctrl+Shift+F", defaultKeys: "Ctrl+Shift+F" },
{ id: "split", label: "Split View", keys: "Ctrl+\\", defaultKeys: "Ctrl+\\" },
{ id: "close-tab", label: "Close Tab", keys: "Ctrl+W", defaultKeys: "Ctrl+W" },
{ id: "search-replace", label: "Search & Replace", keys: "Ctrl+H", defaultKeys: "Ctrl+H" },
{ id: "export-pdf", label: "Export PDF", keys: "Ctrl+Shift+E", defaultKeys: "Ctrl+Shift+E" },
];
/**
* ShortcutsEditor View and customize keyboard shortcuts.
*/
export function ShortcutsEditor({ onClose }: { onClose: () => void }) {
const { vaultPath } = useVault();
const [shortcuts, setShortcuts] = useState<Shortcut[]>(DEFAULT_SHORTCUTS);
const [recording, setRecording] = useState<string | null>(null);
useEffect(() => {
if (!vaultPath) return;
loadShortcuts(vaultPath).then(json => {
try {
const overrides = JSON.parse(json);
setShortcuts(prev => prev.map(s => ({
...s,
keys: overrides[s.id] || s.defaultKeys,
})));
} catch { }
});
}, [vaultPath]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!recording) return;
e.preventDefault();
e.stopPropagation();
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
if (e.shiftKey) parts.push("Shift");
if (e.altKey) parts.push("Alt");
if (!["Control", "Shift", "Alt", "Meta"].includes(e.key)) {
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
}
if (parts.length > 1 || (!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey)) {
const combo = parts.join("+");
setShortcuts(prev => prev.map(s =>
s.id === recording ? { ...s, keys: combo } : s
));
setRecording(null);
}
}, [recording]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
const save = useCallback(async () => {
if (!vaultPath) return;
const overrides: Record<string, string> = {};
shortcuts.forEach(s => {
if (s.keys !== s.defaultKeys) overrides[s.id] = s.keys;
});
await saveShortcuts(vaultPath, JSON.stringify(overrides, null, 2));
}, [vaultPath, shortcuts]);
const reset = (id: string) => {
setShortcuts(prev => prev.map(s =>
s.id === id ? { ...s, keys: s.defaultKeys } : s
));
};
return (
<div className="shortcuts-overlay" onClick={onClose}>
<div className="shortcuts-modal" onClick={e => e.stopPropagation()}>
<div className="shortcuts-header">
<h3 className="shortcuts-title"> Keyboard Shortcuts</h3>
<div className="shortcuts-header-actions">
<button className="shortcuts-save" onClick={save}>Save</button>
<button className="shortcuts-close" onClick={onClose}></button>
</div>
</div>
<div className="shortcuts-list">
{shortcuts.map(s => (
<div key={s.id} className={`shortcut-row ${recording === s.id ? "recording" : ""}`}>
<span className="shortcut-label">{s.label}</span>
<div className="shortcut-keys-area">
<button
className={`shortcut-keys ${recording === s.id ? "listening" : ""}`}
onClick={() => setRecording(recording === s.id ? null : s.id)}
>
{recording === s.id ? "Press keys…" : s.keys}
</button>
{s.keys !== s.defaultKeys && (
<button className="shortcut-reset" onClick={() => reset(s.id)}></button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,20 +1,67 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect, useRef } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useVault } from "../App";
import { writeNote } from "../lib/commands";
import type { NoteEntry } from "../lib/commands";
import { writeNote, deleteNote, renameNote, updateWikilinks, searchVault, listTags, listRecentVaults } from "../lib/commands";
import type { NoteEntry, SearchResult, TagInfo } from "../lib/commands";
export function Sidebar() {
const { notes, vaultPath, refreshNotes } = useVault();
const { notes, vaultPath, refreshNotes, favorites, toggleFavorite, recentNotes, setSplitNote, switchVault } = useVault();
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [tags, setTags] = useState<TagInfo[]>([]);
const [activeTag, setActiveTag] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entry: NoteEntry } | null>(null);
const [renamingPath, setRenamingPath] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [vaultDropdownOpen, setVaultDropdownOpen] = useState(false);
const [recentVaults, setRecentVaults] = useState<string[]>([]);
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
const navigate = useNavigate();
const location = useLocation();
// Load recent vaults
useEffect(() => {
listRecentVaults().then(setRecentVaults).catch(() => setRecentVaults([]));
}, [vaultPath]);
// Debounced full-text search
useEffect(() => {
if (!search.trim() || search.length < 2 || !vaultPath) {
setSearchResults([]);
return;
}
const timer = setTimeout(async () => {
try {
const results = await searchVault(vaultPath, search);
setSearchResults(results.slice(0, 15));
} catch {
setSearchResults([]);
}
}, 300);
return () => clearTimeout(timer);
}, [search, vaultPath]);
// Load tags
useEffect(() => {
if (!vaultPath) return;
listTags(vaultPath).then(setTags).catch(() => setTags([]));
}, [vaultPath, notes]);
const filteredNotes = useMemo(() => {
if (!search.trim()) return notes;
return filterNotes(notes, search.toLowerCase());
}, [notes, search]);
let filtered = notes;
if (search.trim()) {
filtered = filterNotes(filtered, search.toLowerCase());
}
if (activeTag) {
// Find which notes have this tag
const tagInfo = tags.find(t => t.tag === activeTag);
if (tagInfo) {
filtered = filterByPaths(filtered, new Set(tagInfo.notes));
}
}
return filtered;
}, [notes, search, activeTag, tags]);
const handleCreateNote = async () => {
const name = prompt("Note name:");
@ -34,23 +81,162 @@ export function Sidebar() {
});
};
// Context menu handlers
const handleContextMenu = (e: React.MouseEvent, entry: NoteEntry) => {
if (entry.is_dir) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, entry });
};
const handleDelete = async () => {
if (!contextMenu) return;
const { entry } = contextMenu;
setContextMenu(null);
const confirm = window.confirm(`Delete "${entry.name}"? This cannot be undone.`);
if (!confirm) return;
try {
await deleteNote(vaultPath, entry.path);
await refreshNotes();
// If deleted note is current, go home
if (location.pathname === `/note/${encodeURIComponent(entry.path)}`) {
navigate("/");
}
} catch (e) {
console.error("Delete failed:", e);
}
};
const handleRename = () => {
if (!contextMenu) return;
const { entry } = contextMenu;
setContextMenu(null);
setRenamingPath(entry.path);
setRenameValue(entry.name);
};
const submitRename = async () => {
if (!renamingPath || !renameValue.trim()) {
setRenamingPath(null);
return;
}
const oldName = renamingPath.replace(/\.md$/, "").split("/").pop() || "";
const newName = renameValue.trim();
if (oldName === newName) {
setRenamingPath(null);
return;
}
const dir = renamingPath.includes("/")
? renamingPath.substring(0, renamingPath.lastIndexOf("/") + 1)
: "";
const newPath = `${dir}${newName}.md`;
try {
await renameNote(vaultPath, renamingPath, newPath);
await updateWikilinks(vaultPath, oldName, newName);
await refreshNotes();
// If renamed note is current, navigate to new path
if (location.pathname === `/note/${encodeURIComponent(renamingPath)}`) {
navigate(`/note/${encodeURIComponent(newPath)}`, { replace: true });
}
} catch (e) {
console.error("Rename failed:", e);
alert(`Rename failed: ${e}`);
}
setRenamingPath(null);
};
// Close context menu on click anywhere
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [contextMenu]);
// Drag & drop handler
const handleDrop = async (targetDir: string, sourcePath: string) => {
setDragOverPath(null);
if (!sourcePath || sourcePath === targetDir) return;
const fileName = sourcePath.split("/").pop() || "";
const newPath = targetDir ? `${targetDir}/${fileName}` : fileName;
if (newPath === sourcePath) return;
try {
await renameNote(vaultPath, sourcePath, newPath);
await refreshNotes();
if (location.pathname === `/note/${encodeURIComponent(sourcePath)}`) {
navigate(`/note/${encodeURIComponent(newPath)}`, { replace: true });
}
} catch (e) {
console.error("Move failed:", e);
}
};
const vaultName = vaultPath.split("/").pop() || "vault";
return (
<aside className="sidebar">
{/* ── Brand + Search ── */}
{/* ── Brand + Vault Switcher ── */}
<div className="sidebar-header">
<div className="sidebar-brand">
<div className="sidebar-brand-icon">📝</div>
<span className="sidebar-brand-text">GRAPH NOTES</span>
<div
className="sidebar-brand-clickable"
onClick={() => setVaultDropdownOpen(v => !v)}
>
<div className="sidebar-brand-icon">📝</div>
<div>
<span className="sidebar-brand-text">GRAPH NOTES</span>
<span className="sidebar-vault-name">{vaultName}</span>
</div>
</div>
<div style={{ flex: 1 }} />
<button
className="sidebar-new-btn"
onClick={handleCreateNote}
title="New note"
title="New note (⌘N)"
>
+
</button>
</div>
{/* Vault Dropdown */}
{vaultDropdownOpen && (
<div className="vault-dropdown">
<div className="vault-dropdown-label">Switch vault</div>
{recentVaults.filter(v => v !== vaultPath).map(v => (
<button
key={v}
className="vault-dropdown-item"
onClick={() => { switchVault(v); setVaultDropdownOpen(false); }}
>
📁 {v.split("/").pop()}
<span className="vault-dropdown-path">{v}</span>
</button>
))}
{recentVaults.filter(v => v !== vaultPath).length === 0 && (
<div className="vault-dropdown-empty">No other vaults</div>
)}
<button
className="vault-dropdown-item vault-dropdown-add"
onClick={async () => {
const path = prompt("Enter vault directory path:");
if (path?.trim()) {
await switchVault(path.trim());
setVaultDropdownOpen(false);
}
}}
>
Open folder...
</button>
</div>
)}
<div className="sidebar-search">
<svg className="sidebar-search-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" />
@ -58,7 +244,7 @@ export function Sidebar() {
</svg>
<input
type="text"
placeholder="Search notes..."
placeholder="Search notes... (⌘K)"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@ -73,6 +259,7 @@ export function Sidebar() {
>
<span className="sidebar-action-icon">📅</span>
Daily Note
<span className="shortcut-hint">D</span>
</button>
<button
className={`sidebar-action ${location.pathname === "/graph" ? "active" : ""}`}
@ -80,9 +267,179 @@ export function Sidebar() {
>
<span className="sidebar-action-icon">🔮</span>
Graph View
<span className="shortcut-hint">G</span>
</button>
<button
className={`sidebar-action ${location.pathname === "/calendar" ? "active" : ""}`}
onClick={() => navigate("/calendar")}
>
<span className="sidebar-action-icon">📅</span>
Calendar
</button>
<button
className={`sidebar-action ${location.pathname === "/kanban" ? "active" : ""}`}
onClick={() => navigate("/kanban")}
>
<span className="sidebar-action-icon">📋</span>
Kanban Board
</button>
<button
className={`sidebar-action ${location.pathname === "/flashcards" ? "active" : ""}`}
onClick={() => navigate("/flashcards")}
>
<span className="sidebar-action-icon">🎴</span>
Flashcards
</button>
<button
className={`sidebar-action ${location.pathname === "/database" ? "active" : ""}`}
onClick={() => navigate("/database")}
>
<span className="sidebar-action-icon">📊</span>
Database
</button>
<button
className="sidebar-action"
onClick={() => {
const name = prompt("Canvas name:");
if (name?.trim()) navigate(`/whiteboard/${encodeURIComponent(name.trim())}`);
}}
>
<span className="sidebar-action-icon">🎨</span>
Whiteboard
</button>
<button
className={`sidebar-action ${location.pathname === "/timeline" ? "active" : ""}`}
onClick={() => navigate("/timeline")}
>
<span className="sidebar-action-icon">📅</span>
Timeline
</button>
<button
className="sidebar-action"
onClick={async () => {
try {
const { randomNote } = await import("../lib/commands");
const name = await randomNote(vaultPath);
navigate(`/note/${encodeURIComponent(name)}`);
} catch { }
}}
>
<span className="sidebar-action-icon">🎲</span>
Random Note
</button>
<button
className={`sidebar-action ${location.pathname === "/analytics" ? "active" : ""}`}
onClick={() => navigate("/analytics")}
>
<span className="sidebar-action-icon">📊</span>
Analytics
</button>
<button
className={`sidebar-action ${location.pathname === "/integrity" ? "active" : ""}`}
onClick={() => navigate("/integrity")}
>
<span className="sidebar-action-icon">🛡</span>
Integrity
</button>
<button
className={`sidebar-action ${location.pathname === "/audit-log" ? "active" : ""}`}
onClick={() => navigate("/audit-log")}
>
<span className="sidebar-action-icon">📋</span>
Audit Log
</button>
</div>
{/* ── Search Results ── */}
{search.trim().length >= 2 && searchResults.length > 0 && (
<div className="sidebar-search-results">
<div className="sidebar-section-label">
<span>Search Results</span>
<span className="badge badge-purple">{searchResults.length}</span>
</div>
{searchResults.map((result, i) => (
<button
key={`${result.path}-${i}`}
className="search-result-item"
onClick={() => navigate(`/note/${encodeURIComponent(result.path)}`)}
>
<span className="search-result-name">📄 {result.name}</span>
<span className="search-result-context">{result.context}</span>
</button>
))}
</div>
)}
{/* ── Tags ── */}
{tags.length > 0 && (
<div className="sidebar-tags">
<div className="sidebar-section-label">
<span>Tags</span>
<span className="badge badge-muted">{tags.length}</span>
</div>
<div className="tag-list">
{activeTag && (
<button
className="tag-chip tag-chip-clear"
onClick={() => setActiveTag(null)}
>
Clear
</button>
)}
{tags.slice(0, 20).map(tag => (
<button
key={tag.tag}
className={`tag-chip ${activeTag === tag.tag ? "active" : ""}`}
onClick={() => setActiveTag(activeTag === tag.tag ? null : tag.tag)}
>
{tag.tag}
<span className="tag-count">{tag.count}</span>
</button>
))}
</div>
</div>
)}
{/* ── Favorites ── */}
{favorites.length > 0 && (
<div className="sidebar-favorites">
<div className="sidebar-section-label">
<span> Favorites</span>
</div>
{favorites.slice(0, 10).map(fav => (
<button
key={fav}
className={`tree-item ${location.pathname === `/note/${encodeURIComponent(fav)}` ? "active" : ""}`}
style={{ paddingLeft: "16px" }}
onClick={() => navigate(`/note/${encodeURIComponent(fav)}`)}
>
<span className="tree-item-icon">📌</span>
<span className="tree-item-label">{fav.replace(/\.md$/, "").split("/").pop()}</span>
</button>
))}
</div>
)}
{/* ── Recent Notes ── */}
{recentNotes.length > 0 && (
<div className="sidebar-recents">
<div className="sidebar-section-label">
<span>🕓 Recent</span>
</div>
{recentNotes.slice(0, 5).map(note => (
<button
key={note}
className={`tree-item ${location.pathname === `/note/${encodeURIComponent(note)}` ? "active" : ""}`}
style={{ paddingLeft: "16px" }}
onClick={() => navigate(`/note/${encodeURIComponent(note)}`)}
>
<span className="tree-item-icon">📄</span>
<span className="tree-item-label">{note.replace(/\.md$/, "").split("/").pop()}</span>
</button>
))}
</div>
)}
{/* ── File Tree ── */}
<div className="sidebar-tree">
<div className="sidebar-section-label">
@ -93,43 +450,112 @@ export function Sidebar() {
entries={filteredNotes}
collapsed={collapsed}
onToggle={toggleFolder}
onContextMenu={handleContextMenu}
renamingPath={renamingPath}
renameValue={renameValue}
setRenameValue={setRenameValue}
submitRename={submitRename}
onDrop={handleDrop}
dragOverPath={dragOverPath}
setDragOverPath={setDragOverPath}
depth={0}
/>
{filteredNotes.length === 0 && (
<p style={{ padding: "24px 16px", fontSize: 11, color: "var(--text-muted)", textAlign: "center" }}>
<p
style={{
padding: "24px 16px",
fontSize: 11,
color: "var(--text-muted)",
textAlign: "center",
}}
>
{search ? "No matching notes" : "No notes yet"}
</p>
)}
</div>
{/* ── Context Menu ── */}
{contextMenu && (
<div
className="context-menu"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={e => e.stopPropagation()}
>
<button className="context-menu-item" onClick={handleRename}>
Rename
</button>
<button className="context-menu-item" onClick={() => {
if (contextMenu) {
toggleFavorite(contextMenu.entry.path);
setContextMenu(null);
}
}}>
{contextMenu && favorites.includes(contextMenu.entry.path) ? "⭐ Unfavorite" : "☆ Favorite"}
</button>
<button className="context-menu-item" onClick={() => {
if (contextMenu) {
setSplitNote(contextMenu.entry.path);
setContextMenu(null);
}
}}>
Open in split
</button>
<div className="context-menu-divider" />
<button className="context-menu-item context-menu-danger" onClick={handleDelete}>
🗑 Delete
</button>
</div>
)}
</aside>
);
}
/* ── Recursive File Tree ────────────────────────────────────── */
function NoteTree({
entries, collapsed, onToggle, depth,
entries, collapsed, onToggle, onContextMenu, renamingPath, renameValue, setRenameValue, submitRename, onDrop, dragOverPath, setDragOverPath, depth,
}: {
entries: NoteEntry[];
collapsed: Set<string>;
onToggle: (path: string) => void;
onContextMenu: (e: React.MouseEvent, entry: NoteEntry) => void;
renamingPath: string | null;
renameValue: string;
setRenameValue: (v: string) => void;
submitRename: () => void;
onDrop: (targetDir: string, sourcePath: string) => void;
dragOverPath: string | null;
setDragOverPath: (path: string | null) => void;
depth: number;
}) {
const navigate = useNavigate();
const location = useLocation();
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renamingPath) renameInputRef.current?.focus();
}, [renamingPath]);
return (
<ul style={{ listStyle: "none" }}>
{entries.map((entry) => {
const isActive = location.pathname === `/note/${encodeURIComponent(entry.path)}`;
const isCollapsed = collapsed.has(entry.path);
const isRenaming = renamingPath === entry.path;
if (entry.is_dir) {
return (
<li key={entry.path}>
<button
className="tree-item"
className={`tree-item ${dragOverPath === entry.path ? "drag-over" : ""}`}
style={{ paddingLeft: `${14 + depth * 16}px` }}
onClick={() => onToggle(entry.path)}
onDragOver={e => { e.preventDefault(); setDragOverPath(entry.path); }}
onDragLeave={() => setDragOverPath(null)}
onDrop={e => {
e.preventDefault();
const source = e.dataTransfer.getData("text/plain");
onDrop(entry.path, source);
}}
>
<span
className="tree-item-chevron"
@ -141,7 +567,20 @@ function NoteTree({
<span className="tree-item-label" style={{ fontWeight: 500 }}>{entry.name}</span>
</button>
{!isCollapsed && entry.children && (
<NoteTree entries={entry.children} collapsed={collapsed} onToggle={onToggle} depth={depth + 1} />
<NoteTree
entries={entry.children}
collapsed={collapsed}
onToggle={onToggle}
onContextMenu={onContextMenu}
renamingPath={renamingPath}
renameValue={renameValue}
setRenameValue={setRenameValue}
submitRename={submitRename}
onDrop={onDrop}
dragOverPath={dragOverPath}
setDragOverPath={setDragOverPath}
depth={depth + 1}
/>
)}
</li>
);
@ -149,14 +588,37 @@ function NoteTree({
return (
<li key={entry.path}>
<button
className={`tree-item ${isActive ? "active" : ""}`}
style={{ paddingLeft: `${14 + depth * 16}px` }}
onClick={() => navigate(`/note/${encodeURIComponent(entry.path)}`)}
>
<span className="tree-item-icon">📄</span>
<span className="tree-item-label">{entry.name.replace(/\.md$/, "")}</span>
</button>
{isRenaming ? (
<div
className="tree-item"
style={{ paddingLeft: `${14 + depth * 16}px` }}
>
<span className="tree-item-icon">📄</span>
<input
ref={renameInputRef}
className="rename-input"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") submitRename();
if (e.key === "Escape") submitRename();
}}
onBlur={submitRename}
/>
</div>
) : (
<button
className={`tree-item ${isActive ? "active" : ""}`}
style={{ paddingLeft: `${14 + depth * 16}px` }}
draggable
onDragStart={e => e.dataTransfer.setData("text/plain", entry.path)}
onClick={() => navigate(`/note/${encodeURIComponent(entry.path)}`)}
onContextMenu={e => onContextMenu(e, entry)}
>
<span className="tree-item-icon">📄</span>
<span className="tree-item-label">{entry.name.replace(/\.md$/, "")}</span>
</button>
)}
</li>
);
})}
@ -178,6 +640,19 @@ function filterNotes(entries: NoteEntry[], query: string): NoteEntry[] {
return result;
}
function filterByPaths(entries: NoteEntry[], paths: Set<string>): NoteEntry[] {
const result: NoteEntry[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
const filtered = filterByPaths(entry.children, paths);
if (filtered.length > 0) result.push({ ...entry, children: filtered });
} else if (paths.has(entry.path)) {
result.push(entry);
}
}
return result;
}
function countFiles(entries: NoteEntry[]): number {
let count = 0;
for (const e of entries) {

View file

@ -0,0 +1,104 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface SlashCommand {
id: string;
icon: string;
label: string;
insert: string;
}
const COMMANDS: SlashCommand[] = [
{ id: "h1", icon: "H₁", label: "Heading 1", insert: "# " },
{ id: "h2", icon: "H₂", label: "Heading 2", insert: "## " },
{ id: "h3", icon: "H₃", label: "Heading 3", insert: "### " },
{ id: "bullet", icon: "•", label: "Bullet List", insert: "- " },
{ id: "numbered", icon: "1.", label: "Numbered List", insert: "1. " },
{ id: "todo", icon: "☐", label: "Todo", insert: "- [ ] " },
{ id: "code", icon: "⟨⟩", label: "Code Block", insert: "```\n\n```" },
{ id: "divider", icon: "—", label: "Divider", insert: "---\n" },
{ id: "quote", icon: "❝", label: "Blockquote", insert: "> " },
{ id: "table", icon: "⊞", label: "Table", insert: "| Column 1 | Column 2 |\n| --- | --- |\n| cell | cell |" },
{ id: "mermaid", icon: "◇", label: "Mermaid Diagram", insert: "```mermaid\ngraph LR\n A --> B\n```" },
{ id: "link", icon: "🔗", label: "Wikilink", insert: "[[" },
{ id: "image", icon: "🖼", label: "Image", insert: "![alt text]()" },
{ id: "transclusion", icon: "📎", label: "Transclusion", insert: "![[" },
];
interface Props {
visible: boolean;
position: { top: number; left: number };
query: string;
onSelect: (insert: string) => void;
onClose: () => void;
}
/**
* SlashMenu Inline formatting command menu, triggered by `/`.
*/
export function SlashMenu({ visible, position, query, onSelect, onClose }: Props) {
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const filtered = COMMANDS.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase())
);
// Reset selection when query changes
useEffect(() => {
setSelectedIndex(0);
}, [query]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!visible) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[selectedIndex]) {
onSelect(filtered[selectedIndex].insert);
}
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}, [visible, filtered, selectedIndex, onSelect, onClose]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
// Scroll active item into view
useEffect(() => {
const list = listRef.current;
if (!list) return;
const item = list.children[selectedIndex] as HTMLElement | undefined;
item?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (!visible || filtered.length === 0) return null;
return (
<div
className="slash-menu"
ref={listRef}
style={{ top: position.top, left: position.left }}
>
{filtered.map((cmd, i) => (
<button
key={cmd.id}
className={`slash-menu-item ${i === selectedIndex ? "selected" : ""}`}
onMouseEnter={() => setSelectedIndex(i)}
onClick={() => onSelect(cmd.insert)}
>
<span className="slash-menu-icon">{cmd.icon}</span>
<span className="slash-menu-label">{cmd.label}</span>
</button>
))}
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useParams } from "react-router-dom";
import { useVault } from "../App";
import { readNote } from "../lib/commands";
import { Editor } from "./Editor";
import { Backlinks } from "./Backlinks";
/**
* SplitView Renders two note editors side by side with a draggable divider.
* The left pane shows the current note, the right pane shows the split note.
*/
export function SplitView() {
const { path } = useParams<{ path: string }>();
const { vaultPath, setCurrentNote, setNoteContent, splitNote } = useVault();
const decodedPath = decodeURIComponent(path || "");
const [splitContent, setSplitContent] = useState("");
const [dividerPos, setDividerPos] = useState(50); // percentage
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
// Load primary note
useEffect(() => {
if (!decodedPath || !vaultPath) return;
setCurrentNote(decodedPath);
readNote(vaultPath, decodedPath).then(setNoteContent).catch(() => setNoteContent(""));
}, [decodedPath, vaultPath, setCurrentNote, setNoteContent]);
// Load split note
useEffect(() => {
if (!splitNote || !vaultPath) return;
readNote(vaultPath, splitNote).then(setSplitContent).catch(() => setSplitContent(""));
}, [splitNote, vaultPath]);
// Divider drag
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const pct = ((e.clientX - rect.left) / rect.width) * 100;
setDividerPos(Math.max(25, Math.min(75, pct)));
};
const handleMouseUp = () => {
isDragging.current = false;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, []);
if (!splitNote) {
// No split — render single pane like NoteView
return (
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
<Backlinks />
</div>
);
}
return (
<div className="split-container" ref={containerRef}>
{/* Left pane — primary note */}
<div className="split-pane" style={{ width: `${dividerPos}%` }}>
<main className="flex-1 overflow-y-auto">
<Editor />
</main>
</div>
{/* Divider */}
<div className="split-divider" onMouseDown={handleMouseDown}>
<div className="split-divider-handle" />
</div>
{/* Right pane — split note */}
<div className="split-pane" style={{ width: `${100 - dividerPos}%` }}>
<div className="split-pane-header">
<span className="split-pane-title">
{splitNote.replace(/\.md$/, "").split("/").pop()}
</span>
<SplitCloseButton />
</div>
<div className="flex-1 overflow-y-auto">
<div
className="prose prose-invert max-w-none px-8 py-6"
style={{ color: "var(--text-primary)", lineHeight: 1.8, fontSize: "14px" }}
>
<pre style={{
whiteSpace: "pre-wrap",
fontFamily: "inherit",
margin: 0,
fontSize: "inherit",
color: "var(--text-secondary)",
}}>
{splitContent}
</pre>
</div>
</div>
</div>
</div>
);
}
function SplitCloseButton() {
const { setSplitNote } = useVault();
return (
<button
className="split-close-btn"
onClick={() => setSplitNote(null)}
title="Close split"
>
</button>
);
}

View file

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

67
src/components/TabBar.tsx Normal file
View file

@ -0,0 +1,67 @@
import { useVault } from "../App";
import { saveTabs } from "../lib/commands";
interface Tab {
path: string;
name: string;
}
/**
* TabBar Horizontal tab strip for multi-note editing.
*/
export function TabBar({
tabs,
activeTab,
onSelectTab,
onCloseTab,
onReorder,
}: {
tabs: Tab[];
activeTab: number;
onSelectTab: (index: number) => void;
onCloseTab: (index: number) => void;
onReorder: (from: number, to: number) => void;
}) {
const { vaultPath } = useVault();
let dragIndex: number | null = null;
const handleDragStart = (i: number) => { dragIndex = i; };
const handleDrop = (i: number) => {
if (dragIndex !== null && dragIndex !== i) {
onReorder(dragIndex, i);
}
dragIndex = null;
};
const persistTabs = async () => {
if (vaultPath) {
await saveTabs(vaultPath, JSON.stringify(tabs.map(t => t.path))).catch(() => { });
}
};
return (
<div className="tab-bar">
{tabs.map((tab, i) => (
<div
key={tab.path}
className={`tab-item ${i === activeTab ? "active" : ""}`}
onClick={() => onSelectTab(i)}
draggable
onDragStart={() => handleDragStart(i)}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(i)}
>
<span className="tab-name">{tab.name}</span>
<button
className="tab-close"
onClick={e => { e.stopPropagation(); onCloseTab(i); persistTabs(); }}
>
</button>
</div>
))}
</div>
);
}
export type { Tab };

View file

@ -0,0 +1,192 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
interface TableData {
headers: string[];
rows: string[][];
}
/**
* TableEditor Visual markdown table editing.
*/
export function TableEditor({
markdown,
onChange,
}: {
markdown: string;
onChange: (newMarkdown: string) => void;
}) {
const [table, setTable] = useState<TableData>(() => parseTable(markdown));
const [editCell, setEditCell] = useState<{ row: number; col: number } | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editCell && inputRef.current) inputRef.current.focus();
}, [editCell]);
const updateCell = useCallback((row: number, col: number, value: string) => {
setTable(prev => {
const next = { ...prev, rows: prev.rows.map(r => [...r]) };
if (row === -1) {
next.headers = [...next.headers];
next.headers[col] = value;
} else {
next.rows[row][col] = value;
}
return next;
});
}, []);
const commitEdit = useCallback(() => {
setEditCell(null);
onChange(serializeTable(table));
}, [table, onChange]);
const addRow = () => {
setTable(prev => ({
...prev,
rows: [...prev.rows, prev.headers.map(() => "")],
}));
};
const addColumn = () => {
const name = prompt("Column name:");
if (!name?.trim()) return;
setTable(prev => ({
headers: [...prev.headers, name.trim()],
rows: prev.rows.map(r => [...r, ""]),
}));
};
const removeRow = (idx: number) => {
setTable(prev => ({
...prev,
rows: prev.rows.filter((_, i) => i !== idx),
}));
};
const removeColumn = (idx: number) => {
setTable(prev => ({
headers: prev.headers.filter((_, i) => i !== idx),
rows: prev.rows.map(r => r.filter((_, i) => i !== idx)),
}));
};
// Auto-sync to parent on table change
useEffect(() => {
onChange(serializeTable(table));
}, [table]);
return (
<div className="table-editor">
<div className="table-editor-toolbar">
<button className="te-btn" onClick={addRow}>+ Row</button>
<button className="te-btn" onClick={addColumn}>+ Column</button>
</div>
<div className="table-editor-scroll">
<table className="te-table">
<thead>
<tr>
{table.headers.map((h, ci) => (
<th key={ci} className="te-th">
{editCell?.row === -1 && editCell.col === ci ? (
<input
ref={inputRef}
className="te-input"
value={h}
onChange={e => updateCell(-1, ci, e.target.value)}
onBlur={commitEdit}
onKeyDown={e => {
if (e.key === "Enter") commitEdit();
if (e.key === "Tab") {
e.preventDefault();
const nextCol = ci + 1 < table.headers.length ? ci + 1 : 0;
setEditCell({ row: 0, col: nextCol });
}
}}
/>
) : (
<div className="te-header-cell" onClick={() => setEditCell({ row: -1, col: ci })}>
<span>{h || "—"}</span>
<button className="te-remove-col" onClick={(e) => { e.stopPropagation(); removeColumn(ci); }}></button>
</div>
)}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td key={ci} className="te-td">
{editCell?.row === ri && editCell.col === ci ? (
<input
ref={inputRef}
className="te-input"
value={cell}
onChange={e => updateCell(ri, ci, e.target.value)}
onBlur={commitEdit}
onKeyDown={e => {
if (e.key === "Enter") {
const nextRow = ri + 1 < table.rows.length ? ri + 1 : ri;
setEditCell({ row: nextRow, col: ci });
}
if (e.key === "Tab") {
e.preventDefault();
if (ci + 1 < row.length) {
setEditCell({ row: ri, col: ci + 1 });
} else if (ri + 1 < table.rows.length) {
setEditCell({ row: ri + 1, col: 0 });
}
}
}}
/>
) : (
<span className="te-cell" onClick={() => setEditCell({ row: ri, col: ci })}>
{cell || "\u00A0"}
</span>
)}
</td>
))}
<td className="te-remove-row-cell">
<button className="te-remove-row" onClick={() => removeRow(ri)}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/* ── Parse / Serialize ──────────────────────── */
function parseTable(md: string): TableData {
const lines = md.trim().split("\n").filter(l => l.trim());
if (lines.length < 2) return { headers: ["Column 1"], rows: [[""]] };
const splitRow = (line: string) =>
line.split("|").map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length);
const headers = splitRow(lines[0]);
const rows = lines.slice(2).map(splitRow);
return { headers, rows: rows.length ? rows : [[...headers.map(() => "")]] };
}
function serializeTable(table: TableData): string {
const maxWidths = table.headers.map((h, i) =>
Math.max(h.length, ...table.rows.map(r => (r[i] || "").length), 3)
);
const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length));
const headerLine = "| " + table.headers.map((h, i) => pad(h, maxWidths[i])).join(" | ") + " |";
const sepLine = "| " + maxWidths.map(w => "-".repeat(w)).join(" | ") + " |";
const rowLines = table.rows.map(row =>
"| " + row.map((c, i) => pad(c, maxWidths[i])).join(" | ") + " |"
);
return [headerLine, sepLine, ...rowLines].join("\n");
}

View file

@ -0,0 +1,91 @@
import { useMemo, useEffect, useRef, useState } from "react";
import { useVault } from "../App";
interface TocEntry {
level: number;
text: string;
id: string;
}
/**
* TableOfContents Auto-generated outline from note headings.
* Click to scroll, highlights active heading on scroll.
*/
export function TableOfContents() {
const { noteContent, currentNote } = useVault();
const [activeId, setActiveId] = useState("");
const [isOpen, setIsOpen] = useState(true);
const observerRef = useRef<IntersectionObserver | null>(null);
const headings = useMemo(() => {
if (!noteContent) return [];
const entries: TocEntry[] = [];
for (const line of noteContent.split("\n")) {
const match = line.match(/^(#{1,4})\s+(.+)/);
if (match) {
const level = match[1].length;
const text = match[2].trim();
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-$/, "");
entries.push({ level, text, id });
}
}
return entries;
}, [noteContent]);
// Scroll spy — observe headings
useEffect(() => {
if (!headings.length) return;
observerRef.current?.disconnect();
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ rootMargin: "-20% 0px -60% 0px" }
);
observerRef.current = observer;
// Observe heading elements
for (const h of headings) {
const el = document.getElementById(h.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [headings, currentNote]);
const scrollTo = (id: string) => {
const el = document.getElementById(id);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
};
if (!headings.length) return null;
return (
<div className="toc-panel">
<button className="toc-toggle" onClick={() => setIsOpen(!isOpen)}>
<span className="toc-chevron" style={{ transform: isOpen ? "rotate(0)" : "rotate(-90deg)" }}></span>
<span className="toc-label">Contents</span>
<span className="badge badge-muted" style={{ fontSize: 9 }}>{headings.length}</span>
</button>
{isOpen && (
<nav className="toc-list">
{headings.map((h, i) => (
<button
key={`${h.id}-${i}`}
className={`toc-item ${activeId === h.id ? "active" : ""}`}
style={{ paddingLeft: `${8 + (h.level - 1) * 12}px` }}
onClick={() => scrollTo(h.id)}
>
{h.text}
</button>
))}
</nav>
)}
</div>
);
}

View file

@ -0,0 +1,186 @@
import { useState, useEffect } from "react";
import { getTheme, setTheme as setThemeCmd } from "../lib/commands";
interface ThemeConfig {
id: string;
name: string;
preview: { bg: string; accent: string; text: string };
vars: Record<string, string>;
}
const THEMES: ThemeConfig[] = [
{
id: "dark-purple",
name: "Dark Purple",
preview: { bg: "#09090b", accent: "#a78bfa", text: "#fafafa" },
vars: {
"--bg-primary": "#09090b",
"--bg-secondary": "#0f0f14",
"--bg-tertiary": "#18181f",
"--bg-elevated": "#1f1f2c",
"--bg-hover": "#27273a",
"--bg-active": "#2e2e45",
"--text-primary": "#fafafa",
"--text-secondary": "#a1a1aa",
"--text-muted": "#52525b",
"--text-accent": "#a78bfa",
"--accent-purple": "#a78bfa",
"--accent-purple-bright": "#8b5cf6",
"--accent-purple-dim": "rgba(139, 92, 246, 0.25)",
"--accent-purple-glow": "rgba(139, 92, 246, 0.12)",
},
},
{
id: "dark-green",
name: "Dark Emerald",
preview: { bg: "#0a0f0d", accent: "#34d399", text: "#f0fdf4" },
vars: {
"--bg-primary": "#0a0f0d",
"--bg-secondary": "#0d1512",
"--bg-tertiary": "#131f1a",
"--bg-elevated": "#1a2e26",
"--bg-hover": "#234035",
"--bg-active": "#2a5244",
"--text-primary": "#f0fdf4",
"--text-secondary": "#86efac",
"--text-muted": "#4b7a62",
"--text-accent": "#34d399",
"--accent-purple": "#34d399",
"--accent-purple-bright": "#10b981",
"--accent-purple-dim": "rgba(52, 211, 153, 0.25)",
"--accent-purple-glow": "rgba(52, 211, 153, 0.12)",
},
},
{
id: "dark-blue",
name: "Dark Ocean",
preview: { bg: "#0a0c14", accent: "#60a5fa", text: "#f0f9ff" },
vars: {
"--bg-primary": "#0a0c14",
"--bg-secondary": "#0e1220",
"--bg-tertiary": "#141a2e",
"--bg-elevated": "#1c243e",
"--bg-hover": "#263252",
"--bg-active": "#2e3e66",
"--text-primary": "#f0f9ff",
"--text-secondary": "#93c5fd",
"--text-muted": "#4b6a94",
"--text-accent": "#60a5fa",
"--accent-purple": "#60a5fa",
"--accent-purple-bright": "#3b82f6",
"--accent-purple-dim": "rgba(96, 165, 250, 0.25)",
"--accent-purple-glow": "rgba(96, 165, 250, 0.12)",
},
},
{
id: "dark-rose",
name: "Dark Rose",
preview: { bg: "#0f0a0b", accent: "#fb7185", text: "#fff1f2" },
vars: {
"--bg-primary": "#0f0a0b",
"--bg-secondary": "#160e10",
"--bg-tertiary": "#1f1418",
"--bg-elevated": "#2e1c22",
"--bg-hover": "#3e262e",
"--bg-active": "#4e303a",
"--text-primary": "#fff1f2",
"--text-secondary": "#fda4af",
"--text-muted": "#8a5060",
"--text-accent": "#fb7185",
"--accent-purple": "#fb7185",
"--accent-purple-bright": "#f43f5e",
"--accent-purple-dim": "rgba(251, 113, 133, 0.25)",
"--accent-purple-glow": "rgba(251, 113, 133, 0.12)",
},
},
{
id: "light",
name: "Light",
preview: { bg: "#fafafa", accent: "#7c3aed", text: "#18181b" },
vars: {
"--bg-primary": "#fafafa",
"--bg-secondary": "#f4f4f5",
"--bg-tertiary": "#e4e4e7",
"--bg-elevated": "#ffffff",
"--bg-hover": "#e4e4e7",
"--bg-active": "#d4d4d8",
"--text-primary": "#18181b",
"--text-secondary": "#52525b",
"--text-muted": "#a1a1aa",
"--text-accent": "#7c3aed",
"--accent-purple": "#7c3aed",
"--accent-purple-bright": "#6d28d9",
"--accent-purple-dim": "rgba(124, 58, 237, 0.15)",
"--accent-purple-glow": "rgba(124, 58, 237, 0.08)",
},
},
];
/**
* ThemePicker Modal with visual theme previews. Click to apply, persists selection.
*/
export function ThemePicker({ open, onClose }: { open: boolean; onClose: () => void }) {
const [activeTheme, setActiveTheme] = useState("dark-purple");
useEffect(() => {
getTheme().then(setActiveTheme).catch(() => { });
}, []);
const applyTheme = (theme: ThemeConfig) => {
setActiveTheme(theme.id);
const root = document.documentElement;
for (const [key, value] of Object.entries(theme.vars)) {
root.style.setProperty(key, value);
}
setThemeCmd(theme.id).catch(() => { });
onClose();
};
if (!open) return null;
return (
<div className="theme-backdrop" onClick={onClose}>
<div className="theme-modal" onClick={e => e.stopPropagation()}>
<h3 className="theme-modal-title">Choose Theme</h3>
<div className="theme-grid">
{THEMES.map(theme => (
<button
key={theme.id}
className={`theme-card ${activeTheme === theme.id ? "active" : ""}`}
onClick={() => applyTheme(theme)}
>
<div
className="theme-preview"
style={{ background: theme.preview.bg }}
>
<div className="theme-preview-bar" style={{ background: theme.preview.accent }} />
<div className="theme-preview-line" style={{ background: theme.preview.text, opacity: 0.7 }} />
<div className="theme-preview-line short" style={{ background: theme.preview.text, opacity: 0.4 }} />
<div className="theme-preview-dot" style={{ background: theme.preview.accent }} />
</div>
<span className="theme-card-name">{theme.name}</span>
{activeTheme === theme.id && <span className="theme-active-badge">Active</span>}
</button>
))}
</div>
</div>
</div>
);
}
/**
* Load and apply the saved theme on app start.
*/
export function useThemeInit() {
useEffect(() => {
getTheme().then(themeId => {
const theme = THEMES.find(t => t.id === themeId);
if (theme) {
const root = document.documentElement;
for (const [key, value] of Object.entries(theme.vars)) {
root.style.setProperty(key, value);
}
}
}).catch(() => { });
}, []);
}

View file

@ -0,0 +1,98 @@
import { useEffect, useState, useMemo } from "react";
import { useVault } from "../App";
import { listNotesByDate, type NoteByDate } from "../lib/commands";
/**
* TimelineView Chronological note view with visual timeline.
*/
export function TimelineView() {
const { vaultPath, navigateToNote } = useVault();
const [notes, setNotes] = useState<NoteByDate[]>([]);
const [rangeFilter, setRangeFilter] = useState<"all" | "week" | "month" | "year">("all");
useEffect(() => {
if (!vaultPath) return;
listNotesByDate(vaultPath).then(setNotes).catch(() => setNotes([]));
}, [vaultPath]);
// Group by date
const grouped = useMemo(() => {
const now = Date.now() / 1000;
let filtered = notes;
if (rangeFilter === "week") filtered = notes.filter(n => now - n.modified < 7 * 86400);
else if (rangeFilter === "month") filtered = notes.filter(n => now - n.modified < 30 * 86400);
else if (rangeFilter === "year") filtered = notes.filter(n => now - n.modified < 365 * 86400);
const groups: Map<string, NoteByDate[]> = new Map();
filtered.forEach(note => {
const date = new Date(note.modified * 1000);
const key = date.toLocaleDateString("en-US", {
year: "numeric", month: "long", day: "numeric",
});
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(note);
});
return Array.from(groups.entries());
}, [notes, rangeFilter]);
const formatTime = (ts: number) => {
return new Date(ts * 1000).toLocaleTimeString("en-US", {
hour: "2-digit", minute: "2-digit",
});
};
return (
<div className="timeline-view">
<div className="timeline-header">
<h2 className="timeline-title">📅 Timeline</h2>
<div className="timeline-filters">
{(["all", "week", "month", "year"] as const).map(r => (
<button
key={r}
className={`timeline-filter-btn ${rangeFilter === r ? "active" : ""}`}
onClick={() => setRangeFilter(r)}
>
{r === "all" ? "All" : r === "week" ? "7d" : r === "month" ? "30d" : "1y"}
</button>
))}
</div>
</div>
<div className="timeline-content">
{grouped.map(([date, items]) => (
<div key={date} className="timeline-group">
<div className="timeline-date-header">
<div className="timeline-date-dot" />
<span className="timeline-date-text">{date}</span>
<span className="timeline-date-count">{items.length}</span>
</div>
<div className="timeline-items">
{items.map(note => (
<div
key={note.path}
className="timeline-card"
onClick={() => navigateToNote(note.name)}
>
<div className="timeline-card-header">
<span className="timeline-card-name">{note.name}</span>
<span className="timeline-card-time">{formatTime(note.modified)}</span>
</div>
{note.preview && (
<p className="timeline-card-preview">{note.preview}</p>
)}
</div>
))}
</div>
</div>
))}
{grouped.length === 0 && (
<div className="timeline-empty">
<p>No notes found for this time range.</p>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useState, useEffect } from "react";
import { useVault } from "../App";
import { readNote } from "../lib/commands";
import { marked } from "marked";
/**
* TransclusionBlock Renders the content of another note inline.
* Used for ![[note-name]] transclusion syntax.
*/
export function TransclusionBlock({ noteName, depth = 0 }: { noteName: string; depth?: number }) {
const { vaultPath, notes, navigateToNote } = useVault();
const [content, setContent] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [error, setError] = useState(false);
const MAX_DEPTH = 3;
useEffect(() => {
if (depth >= MAX_DEPTH) return;
if (!vaultPath) return;
// Find note path by name
const allPaths = flattenNotes(notes);
const match = allPaths.find(
p => p.replace(/\.md$/, "").split("/").pop()?.toLowerCase() === noteName.toLowerCase()
);
if (!match) {
setError(true);
return;
}
readNote(vaultPath, match)
.then(setContent)
.catch(() => setError(true));
}, [vaultPath, noteName, notes, depth]);
if (depth >= MAX_DEPTH) {
return (
<div className="transclusion-block transclusion-limit">
<span className="transclusion-icon"></span>
<span>Transclusion depth limit reached for <strong>{noteName}</strong></span>
</div>
);
}
if (error) {
return (
<div className="transclusion-block transclusion-error">
<span className="transclusion-icon"></span>
<span>Note not found: <strong>{noteName}</strong></span>
</div>
);
}
if (content === null) {
return (
<div className="transclusion-block transclusion-loading">
<span className="transclusion-icon"></span>
<span>Loading {noteName}...</span>
</div>
);
}
// Process transclusions recursively
const processedContent = content.replace(
/!\[\[([^\]]+)\]\]/g,
(_m, target) => `<div class="transclusion-nested" data-note="${target.trim()}"></div>`
);
// Render markdown
let html = marked(processedContent, { async: false }) as string;
// Process wikilinks
html = html.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_m, target, display) => {
const label = display?.trim() || target.trim();
return `<span class="wikilink" data-target="${target.trim()}">${label}</span>`;
}
);
return (
<div className={`transclusion-block ${collapsed ? "collapsed" : ""}`}>
<div className="transclusion-header">
<button
className="transclusion-toggle"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? "▶" : "▾"}
</button>
<span className="transclusion-name" onClick={() => navigateToNote(noteName)}>
📄 {noteName}
</span>
<button
className="transclusion-open"
onClick={() => navigateToNote(noteName)}
title="Open note"
>
</button>
</div>
{!collapsed && (
<div
className="transclusion-content"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</div>
);
}
function flattenNotes(entries: { path: string; is_dir: boolean; children?: any[] }[]): string[] {
const paths: string[] = [];
for (const entry of entries) {
if (entry.is_dir && entry.children) {
paths.push(...flattenNotes(entry.children));
} else if (!entry.is_dir) {
paths.push(entry.path);
}
}
return paths;
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

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

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

View file

@ -25,6 +25,20 @@ export interface GraphData {
edges: GraphEdge[];
}
export interface SearchResult {
path: string;
name: string;
line_number: number;
context: string;
score: number;
}
export interface TagInfo {
tag: string;
count: number;
notes: string[];
}
/* ── Commands ───────────────────────────────────────────────── */
export async function listNotes(vaultPath: string): Promise<NoteEntry[]> {
return invoke<NoteEntry[]>("list_notes", { vaultPath });
@ -61,3 +75,491 @@ export async function setVaultPath(path: string): Promise<void> {
export async function ensureVault(vaultPath: string): Promise<void> {
return invoke("ensure_vault", { vaultPath });
}
export async function searchVault(vaultPath: string, query: string): Promise<SearchResult[]> {
return invoke<SearchResult[]>("search_vault", { vaultPath, query });
}
export async function renameNote(vaultPath: string, oldPath: string, newPath: string): Promise<void> {
return invoke("rename_note", { vaultPath, oldPath, newPath });
}
export async function updateWikilinks(vaultPath: string, oldName: string, newName: string): Promise<number> {
return invoke<number>("update_wikilinks", { vaultPath, oldName, newName });
}
export async function listTags(vaultPath: string): Promise<TagInfo[]> {
return invoke<TagInfo[]>("list_tags", { vaultPath });
}
export interface TemplateInfo {
name: string;
path: string;
}
export async function readNotePreview(vaultPath: string, notePath: string, maxChars?: number): Promise<string> {
return invoke<string>("read_note_preview", { vaultPath, notePath, maxChars });
}
export async function listRecentVaults(): Promise<string[]> {
return invoke<string[]>("list_recent_vaults");
}
export async function addVault(vaultPath: string): Promise<void> {
return invoke("add_vault", { vaultPath });
}
export async function listTemplates(vaultPath: string): Promise<TemplateInfo[]> {
return invoke<TemplateInfo[]>("list_templates", { vaultPath });
}
export async function createFromTemplate(vaultPath: string, templatePath: string, noteName: string): Promise<string> {
return invoke<string>("create_from_template", { vaultPath, templatePath, noteName });
}
export async function getFavorites(vaultPath: string): Promise<string[]> {
return invoke<string[]>("get_favorites", { vaultPath });
}
export async function setFavorites(vaultPath: string, favorites: string[]): Promise<void> {
return invoke("set_favorites", { vaultPath, favorites });
}
/* ── v0.4 Commands ──────────────────────────────────────── */
export async function parseFrontmatter(vaultPath: string, notePath: string): Promise<Record<string, string>> {
return invoke<Record<string, string>>("parse_frontmatter", { vaultPath, notePath });
}
export async function writeFrontmatter(vaultPath: string, notePath: string, frontmatter: Record<string, string>): Promise<void> {
return invoke("write_frontmatter", { vaultPath, notePath, frontmatter });
}
export async function saveAttachment(vaultPath: string, fileName: string, data: number[]): Promise<string> {
return invoke<string>("save_attachment", { vaultPath, fileName, data });
}
export async function listAttachments(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_attachments", { vaultPath });
}
export async function listDailyNotes(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_daily_notes", { vaultPath });
}
export async function getTheme(): Promise<string> {
return invoke<string>("get_theme");
}
export async function setTheme(theme: string): Promise<void> {
return invoke("set_theme", { theme });
}
export async function exportNoteHtml(vaultPath: string, notePath: string): Promise<string> {
return invoke<string>("export_note_html", { vaultPath, notePath });
}
/* ── v0.5 Commands ──────────────────────────────────────── */
export interface TaskItem {
text: string;
state: string;
source_path: string;
line_number: number;
}
export interface SnapshotInfo {
timestamp: string;
filename: string;
size: number;
}
export interface ReplaceResult {
path: string;
count: number;
}
export async function listTasks(vaultPath: string): Promise<TaskItem[]> {
return invoke<TaskItem[]>("list_tasks", { vaultPath });
}
export async function toggleTask(vaultPath: string, notePath: string, lineNumber: number, newState: string): Promise<void> {
return invoke("toggle_task", { vaultPath, notePath, lineNumber, newState });
}
export async function saveSnapshot(vaultPath: string, notePath: string): Promise<string> {
return invoke<string>("save_snapshot", { vaultPath, notePath });
}
export async function listSnapshots(vaultPath: string, notePath: string): Promise<SnapshotInfo[]> {
return invoke<SnapshotInfo[]>("list_snapshots", { vaultPath, notePath });
}
export async function readSnapshot(vaultPath: string, notePath: string, snapshotName: string): Promise<string> {
return invoke<string>("read_snapshot", { vaultPath, notePath, snapshotName });
}
export async function searchReplaceVault(vaultPath: string, search: string, replace: string, dryRun: boolean): Promise<ReplaceResult[]> {
return invoke<ReplaceResult[]>("search_replace_vault", { vaultPath, search, replace, dryRun });
}
export async function getWritingGoal(vaultPath: string, notePath: string): Promise<number> {
return invoke<number>("get_writing_goal", { vaultPath, notePath });
}
export async function setWritingGoal(vaultPath: string, notePath: string, goal: number): Promise<void> {
return invoke("set_writing_goal", { vaultPath, notePath, goal });
}
/* ── v0.6 Commands ──────────────────────────────────────── */
export interface Flashcard {
question: string;
answer: string;
source_path: string;
line_number: number;
due: string | null;
interval: number;
ease: number;
}
export async function extractToNote(vaultPath: string, sourcePath: string, selectedText: string, newNoteName: string): Promise<string> {
return invoke<string>("extract_to_note", { vaultPath, sourcePath, selectedText, newNoteName });
}
export async function mergeNotes(vaultPath: string, sourcePath: string, targetPath: string): Promise<void> {
return invoke("merge_notes", { vaultPath, sourcePath, targetPath });
}
export async function encryptNote(vaultPath: string, notePath: string, password: string): Promise<void> {
return invoke("encrypt_note", { vaultPath, notePath, password });
}
export async function decryptNote(vaultPath: string, notePath: string, password: string): Promise<string> {
return invoke<string>("decrypt_note", { vaultPath, notePath, password });
}
export async function isEncrypted(vaultPath: string, notePath: string): Promise<boolean> {
return invoke<boolean>("is_encrypted", { vaultPath, notePath });
}
export async function listFlashcards(vaultPath: string): Promise<Flashcard[]> {
return invoke<Flashcard[]>("list_flashcards", { vaultPath });
}
export async function updateCardSchedule(vaultPath: string, cardId: string, quality: number): Promise<void> {
return invoke("update_card_schedule", { vaultPath, cardId, quality });
}
export async function saveFoldState(vaultPath: string, notePath: string, folds: number[]): Promise<void> {
return invoke("save_fold_state", { vaultPath, notePath, folds });
}
export async function loadFoldState(vaultPath: string, notePath: string): Promise<number[]> {
return invoke<number[]>("load_fold_state", { vaultPath, notePath });
}
export async function getCustomCss(): Promise<string> {
return invoke<string>("get_custom_css");
}
export async function setCustomCss(css: string): Promise<void> {
return invoke("set_custom_css", { css });
}
export async function saveWorkspace(vaultPath: string, name: string, state: string): Promise<void> {
return invoke("save_workspace", { vaultPath, name, state });
}
export async function loadWorkspace(vaultPath: string, name: string): Promise<string> {
return invoke<string>("load_workspace", { vaultPath, name });
}
export async function listWorkspaces(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_workspaces", { vaultPath });
}
export async function saveTabs(vaultPath: string, tabs: string): Promise<void> {
return invoke("save_tabs", { vaultPath, tabs });
}
export async function loadTabs(vaultPath: string): Promise<string> {
return invoke<string>("load_tabs", { vaultPath });
}
/* ── v0.7 Commands ─────────────────────────────────────── */
// Canvas
export async function saveCanvas(vaultPath: string, name: string, data: string): Promise<void> {
return invoke("save_canvas", { vaultPath, name, data });
}
export async function loadCanvas(vaultPath: string, name: string): Promise<string> {
return invoke<string>("load_canvas", { vaultPath, name });
}
export async function listCanvases(vaultPath: string): Promise<string[]> {
return invoke<string[]>("list_canvases", { vaultPath });
}
// Frontmatter Query
export interface FrontmatterRow {
path: string;
title: string;
fields: Record<string, string>;
}
export async function queryFrontmatter(vaultPath: string): Promise<FrontmatterRow[]> {
return invoke<FrontmatterRow[]>("query_frontmatter", { vaultPath });
}
// Backlink Context
export interface BacklinkContext {
source_path: string;
source_name: string;
excerpt: string;
}
export async function getBacklinkContext(vaultPath: string, noteName: string): Promise<BacklinkContext[]> {
return invoke<BacklinkContext[]>("get_backlink_context", { vaultPath, noteName });
}
// Dataview
export interface DataviewResult {
columns: string[];
rows: string[][];
}
export async function runDataviewQuery(vaultPath: string, query: string): Promise<DataviewResult> {
return invoke<DataviewResult>("run_dataview_query", { vaultPath, query });
}
// Git
export async function gitStatus(vaultPath: string): Promise<string> {
return invoke<string>("git_status", { vaultPath });
}
export async function gitCommit(vaultPath: string, message: string): Promise<string> {
return invoke<string>("git_commit", { vaultPath, message });
}
export async function gitPull(vaultPath: string): Promise<string> {
return invoke<string>("git_pull", { vaultPath });
}
export async function gitPush(vaultPath: string): Promise<string> {
return invoke<string>("git_push", { vaultPath });
}
export async function gitInit(vaultPath: string): Promise<string> {
return invoke<string>("git_init", { vaultPath });
}
/* ── v0.8 Commands ─────────────────────────────────────── */
export async function suggestLinks(vaultPath: string, partial: string): Promise<string[]> {
return invoke<string[]>("suggest_links", { vaultPath, partial });
}
export interface NoteByDate {
path: string;
name: string;
modified: number;
preview: string;
}
export async function listNotesByDate(vaultPath: string): Promise<NoteByDate[]> {
return invoke<NoteByDate[]>("list_notes_by_date", { vaultPath });
}
export async function randomNote(vaultPath: string): Promise<string> {
return invoke<string>("random_note", { vaultPath });
}
/* ── v0.9 Commands ─────────────────────────────────────── */
export async function exportVaultZip(vaultPath: string, outputPath: string): Promise<string> {
return invoke<string>("export_vault_zip", { vaultPath, outputPath });
}
export async function importFolder(vaultPath: string, sourcePath: string): Promise<number> {
return invoke<number>("import_folder", { vaultPath, sourcePath });
}
export async function saveShortcuts(vaultPath: string, shortcutsJson: string): Promise<void> {
return invoke<void>("save_shortcuts", { vaultPath, shortcutsJson });
}
export async function loadShortcuts(vaultPath: string): Promise<string> {
return invoke<string>("load_shortcuts", { vaultPath });
}
export async function getPinned(vaultPath: string): Promise<string[]> {
return invoke<string[]>("get_pinned", { vaultPath });
}
export async function setPinned(vaultPath: string, pinned: string[]): Promise<void> {
return invoke<void>("set_pinned", { vaultPath, pinned });
}
/* ── v1.3 Commands ─────────────────────────────────────── */
// Federated Search
export interface FederatedResult {
vault_name: string;
vault_path: string;
note_path: string;
note_name: string;
excerpt: string;
score: number;
}
export async function searchMultiVault(query: string, vaultPaths: string[]): Promise<FederatedResult[]> {
return invoke<FederatedResult[]>("search_multi_vault", { query, vaultPaths });
}
// Note Types
export interface NoteType {
name: string;
icon: string;
fields: string[];
template: string;
}
export async function listNoteTypes(vaultPath: string): Promise<NoteType[]> {
return invoke<NoteType[]>("list_note_types", { vaultPath });
}
export async function createFromType(vaultPath: string, typeName: string, title: string): Promise<string> {
return invoke<string>("create_from_type", { vaultPath, typeName, title });
}
// Reading List
export interface ReadingItem {
note_path: string;
status: 'unread' | 'reading' | 'finished';
progress: number;
added_at: string;
}
export async function getReadingList(vaultPath: string): Promise<string> {
return invoke<string>("get_reading_list", { vaultPath });
}
export async function setReadingList(vaultPath: string, data: string): Promise<void> {
return invoke("set_reading_list", { vaultPath, data });
}
// Plugins
export interface PluginInfo {
name: string;
filename: string;
enabled: boolean;
hooks: string[];
}
export async function listPlugins(vaultPath: string): Promise<PluginInfo[]> {
return invoke<PluginInfo[]>("list_plugins", { vaultPath });
}
export async function togglePlugin(vaultPath: string, name: string, enabled: boolean): Promise<void> {
return invoke("toggle_plugin", { vaultPath, name, enabled });
}
// Vault Registry
export async function getVaultRegistry(): Promise<string[]> {
return invoke<string[]>("get_vault_registry", {});
}
export async function setVaultRegistry(vaults: string[]): Promise<void> {
return invoke("set_vault_registry", { vaults });
}
// RSS Export
export async function exportRss(vaultPath: string, outputDir: string, feedTitle: string, feedUrl: string): Promise<string> {
return invoke<string>("export_rss", { vaultPath, outputDir, feedTitle, feedUrl });
}
// AI Summary
export async function generateSummary(vaultPath: string, notePath: string): Promise<string> {
return invoke<string>("generate_summary", { vaultPath, notePath });
}
export async function getAiConfig(vaultPath: string): Promise<string> {
return invoke<string>("get_ai_config", { vaultPath });
}
export async function setAiConfig(vaultPath: string, config: string): Promise<void> {
return invoke("set_ai_config", { vaultPath, config });
}
/* ── v1.4 Commands ─────────────────────────────────────── */
// Content Checksums
export interface ChecksumMismatch {
path: string;
expected: string;
actual: string;
}
export async function computeChecksums(vaultPath: string): Promise<number> {
return invoke<number>("compute_checksums", { vaultPath });
}
export async function verifyChecksums(vaultPath: string): Promise<ChecksumMismatch[]> {
return invoke<ChecksumMismatch[]>("verify_checksums", { vaultPath });
}
// Vault Integrity Scanner
export interface IntegrityIssue {
severity: string;
category: string;
path: string;
description: string;
}
export async function scanIntegrity(vaultPath: string): Promise<IntegrityIssue[]> {
return invoke<IntegrityIssue[]>("scan_integrity", { vaultPath });
}
// Backups
export interface BackupEntry {
name: string;
size: number;
created: string;
}
export async function createBackup(vaultPath: string): Promise<string> {
return invoke<string>("create_backup", { vaultPath });
}
export async function listBackups(vaultPath: string): Promise<BackupEntry[]> {
return invoke<BackupEntry[]>("list_backups", { vaultPath });
}
export async function restoreBackup(vaultPath: string, backupName: string): Promise<number> {
return invoke<number>("restore_backup", { vaultPath, backupName });
}
// WAL
export interface WalEntry {
timestamp: string;
operation: string;
path: string;
content_hash: string;
status: string;
}
export async function walStatus(vaultPath: string): Promise<WalEntry[]> {
return invoke<WalEntry[]>("wal_status", { vaultPath });
}
export async function walRecover(vaultPath: string): Promise<number> {
return invoke<number>("wal_recover", { vaultPath });
}
// Conflict Detection
export async function checkConflict(vaultPath: string, relativePath: string, expectedMtime: number): Promise<string> {
return invoke<string>("check_conflict", { vaultPath, relativePath, expectedMtime });
}
// Frontmatter Validation
export interface FrontmatterWarning {
line: number;
message: string;
}
export async function validateFrontmatter(content: string): Promise<FrontmatterWarning[]> {
return invoke<FrontmatterWarning[]>("validate_frontmatter", { content });
}
// Orphan Attachments
export interface OrphanAttachment {
path: string;
size: number;
}
export async function findOrphanAttachments(vaultPath: string): Promise<OrphanAttachment[]> {
return invoke<OrphanAttachment[]>("find_orphan_attachments", { vaultPath });
}
// Audit Log
export interface AuditEntry {
timestamp: string;
operation: string;
path: string;
detail: string;
}
export async function getAuditLog(vaultPath: string, limit: number): Promise<AuditEntry[]> {
return invoke<AuditEntry[]>("get_audit_log", { vaultPath, limit });
}

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

@ -0,0 +1,122 @@
/**
* Frontmatter parser/serializer for YAML-style --- blocks
*/
export interface NoteMeta {
title?: string;
tags: string[];
created?: string;
modified?: string;
extra: Record<string, string>;
}
export interface HeadingEntry {
level: number;
text: string;
line: number;
}
export interface NoteWithMeta {
content: string;
meta: NoteMeta;
body: string;
headings: HeadingEntry[];
}
const FM_REGEX = /^---\n([\s\S]*?)\n---\n?/;
/**
* Parse frontmatter from markdown content (client-side)
*/
export function parseFrontmatter(content: string): { meta: NoteMeta; body: string } {
const match = content.match(FM_REGEX);
if (!match) {
return { meta: emptyMeta(), body: content };
}
const yaml = match[1];
const body = content.slice(match[0].length);
const meta = emptyMeta();
for (const line of yaml.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const colonIdx = trimmed.indexOf(":");
if (colonIdx === -1) continue;
const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
const value = trimmed.slice(colonIdx + 1).trim();
switch (key) {
case "title":
meta.title = stripQuotes(value);
break;
case "created":
meta.created = stripQuotes(value);
break;
case "modified":
meta.modified = stripQuotes(value);
break;
case "tags": {
const cleaned = value.replace(/^\[|\]$/g, "");
meta.tags = cleaned
.split(",")
.map((t) => stripQuotes(t.trim()))
.filter(Boolean);
break;
}
default:
meta.extra[key] = value;
}
}
return { meta, body };
}
/**
* Serialize frontmatter + body back to markdown string
*/
export function serializeFrontmatter(meta: NoteMeta, body: string): string {
const lines: string[] = [];
if (meta.title) lines.push(`title: "${meta.title}"`);
if (meta.tags.length > 0) lines.push(`tags: [${meta.tags.join(", ")}]`);
if (meta.created) lines.push(`created: ${meta.created}`);
if (meta.modified) lines.push(`modified: ${meta.modified}`);
for (const [key, val] of Object.entries(meta.extra)) {
lines.push(`${key}: ${val}`);
}
if (lines.length === 0) return body;
return `---\n${lines.join("\n")}\n---\n\n${body}`;
}
/**
* Extract headings from markdown content (client-side)
*/
export function extractHeadings(content: string): HeadingEntry[] {
const headings: HeadingEntry[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^(#{1,6})\s+(.+)$/);
if (match) {
headings.push({
level: match[1].length,
text: match[2].trim(),
line: i + 1,
});
}
}
return headings;
}
function emptyMeta(): NoteMeta {
return { tags: [], extra: {} };
}
function stripQuotes(s: string): string {
return s.replace(/^["']|["']$/g, "");
}

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

@ -0,0 +1,65 @@
/**
* Simple LRU cache for note content.
* Avoids IPC round-trips when navigating back to recently viewed notes.
*/
interface CacheEntry {
content: string;
timestamp: number;
}
export class NoteCache {
private cache: Map<string, CacheEntry>;
private capacity: number;
constructor(capacity = 20) {
this.cache = new Map();
this.capacity = capacity;
}
get(key: string): string | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.content;
}
set(key: string, content: string): void {
// If key exists, remove to refresh position
if (this.cache.has(key)) {
this.cache.delete(key);
}
// Evict LRU if at capacity
if (this.cache.size >= this.capacity) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, { content, timestamp: Date.now() });
}
invalidate(key: string): void {
this.cache.delete(key);
}
invalidateAll(): void {
this.cache.clear();
}
has(key: string): boolean {
return this.cache.has(key);
}
get size(): number {
return this.cache.size;
}
}
// Singleton instance
export const noteCache = new NoteCache(20);

View file

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