Compare commits
10 commits
706c7ac5ad
...
c6ce0b24d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ce0b24d5 | ||
|
|
bf4ef86874 | ||
|
|
d639d40612 | ||
|
|
6fa547802c | ||
|
|
9dcfece5bf | ||
|
|
0d26e63c9a | ||
|
|
c1f556b86b | ||
|
|
d174c7f26d | ||
|
|
2041798048 | ||
|
|
b03237f4c2 |
64 changed files with 17019 additions and 1065 deletions
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal 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
11
.changeset/config.json
Normal 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": []
|
||||
}
|
||||
17
.changeset/initial-release.md
Normal file
17
.changeset/initial-release.md
Normal 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
6
.gitignore
vendored
|
|
@ -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
244
CHANGELOG.md
Normal 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 (H1–H6) 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 `` 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
|
||||
87
README.md
87
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1576
package-lock.json
generated
File diff suppressed because it is too large
Load diff
33
package.json
33
package.json
|
|
@ -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
385
src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
79
src-tauri/src/crypto.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use argon2::Argon2;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
|
||||
|
||||
use crate::{atomic_write, safe_vault_path};
|
||||
|
||||
fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password.as_bytes(), salt, &mut key)
|
||||
.expect("key derivation failed");
|
||||
key
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn encrypt_note(vault_path: String, note_path: String, password: String) -> Result<(), String> {
|
||||
let full = safe_vault_path(&vault_path, ¬e_path)?;
|
||||
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||
|
||||
let salt: [u8; 16] = rand::random();
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
|
||||
let key_bytes = derive_key(&password, &salt);
|
||||
let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher.encrypt(nonce, content.as_bytes())
|
||||
.map_err(|_| "Encryption failed".to_string())?;
|
||||
|
||||
// Format: GRAPHNOTES_ENC:v1:{salt_b64}:{nonce_b64}:{ciphertext_b64}
|
||||
let encoded = format!(
|
||||
"GRAPHNOTES_ENC:v1:{}:{}:{}",
|
||||
B64.encode(salt),
|
||||
B64.encode(nonce_bytes),
|
||||
B64.encode(&ciphertext),
|
||||
);
|
||||
atomic_write(&full, &encoded)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn decrypt_note(vault_path: String, note_path: String, password: String) -> Result<String, String> {
|
||||
let full = safe_vault_path(&vault_path, ¬e_path)?;
|
||||
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||
|
||||
if !content.starts_with("GRAPHNOTES_ENC:v1:") {
|
||||
return Err("Note is not encrypted".to_string());
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = content.splitn(5, ':').collect();
|
||||
if parts.len() != 5 {
|
||||
return Err("Invalid encrypted format".to_string());
|
||||
}
|
||||
|
||||
let salt = B64.decode(parts[2]).map_err(|_| "Invalid salt".to_string())?;
|
||||
let nonce_bytes = B64.decode(parts[3]).map_err(|_| "Invalid nonce".to_string())?;
|
||||
let ciphertext = B64.decode(parts[4]).map_err(|_| "Invalid ciphertext".to_string())?;
|
||||
|
||||
let key_bytes = derive_key(&password, &salt);
|
||||
let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let plaintext = cipher.decrypt(nonce, ciphertext.as_ref())
|
||||
.map_err(|_| "Wrong password".to_string())?;
|
||||
|
||||
String::from_utf8(plaintext).map_err(|_| "Invalid UTF-8".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_encrypted(vault_path: String, note_path: String) -> Result<bool, String> {
|
||||
let full = safe_vault_path(&vault_path, ¬e_path)?;
|
||||
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||
Ok(content.starts_with("GRAPHNOTES_ENC:v1:"))
|
||||
}
|
||||
123
src-tauri/src/export.rs
Normal file
123
src-tauri/src/export.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{safe_vault_path, EXPORT_WIKILINK_RE};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_note_html(vault_path: String, note_path: String) -> Result<String, String> {
|
||||
let full = safe_vault_path(&vault_path, ¬e_path)?;
|
||||
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||
let title = Path::new(¬e_path)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Basic markdown-to-HTML (headings, bold, italic, links, paragraphs)
|
||||
let mut html_body = String::new();
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("---") {
|
||||
continue; // skip frontmatter delimiters
|
||||
}
|
||||
if trimmed.starts_with("# ") {
|
||||
html_body.push_str(&format!("<h1>{}</h1>\n", &trimmed[2..]));
|
||||
} else if trimmed.starts_with("## ") {
|
||||
html_body.push_str(&format!("<h2>{}</h2>\n", &trimmed[3..]));
|
||||
} else if trimmed.starts_with("### ") {
|
||||
html_body.push_str(&format!("<h3>{}</h3>\n", &trimmed[4..]));
|
||||
} else if trimmed.starts_with("- ") {
|
||||
html_body.push_str(&format!("<li>{}</li>\n", &trimmed[2..]));
|
||||
} else if trimmed.is_empty() {
|
||||
html_body.push_str("<br>\n");
|
||||
} else {
|
||||
html_body.push_str(&format!("<p>{}</p>\n", trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
// Replace wikilinks
|
||||
let html_body = EXPORT_WIKILINK_RE.replace_all(&html_body, |caps: ®ex::Captures| {
|
||||
let target = caps.get(1).map_or("", |m| m.as_str()).trim();
|
||||
let label = caps.get(2).map_or(target, |m| m.as_str()).trim();
|
||||
format!("<a href=\"{}.html\">{}</a>", target, label)
|
||||
}).to_string();
|
||||
|
||||
let html = format!(r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; background: #0a0a0c; color: #e4e4e7; line-height: 1.8; }}
|
||||
h1, h2, h3 {{ color: #fafafa; }}
|
||||
a {{ color: #a78bfa; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
code {{ background: #1f1f2c; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }}
|
||||
li {{ margin: 4px 0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{html_body}
|
||||
</body>
|
||||
</html>"#);
|
||||
Ok(html)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_vault_zip(vault_path: String, output_path: String) -> Result<String, String> {
|
||||
use std::io::Write;
|
||||
let vault = Path::new(&vault_path);
|
||||
let out = Path::new(&output_path);
|
||||
|
||||
let file = fs::File::create(out).map_err(|e| e.to_string())?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
for entry in WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_file())
|
||||
{
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(vault).unwrap_or(path).to_string_lossy().to_string();
|
||||
// Skip hidden files, in-progress atomic writes (~tmp suffix)
|
||||
if rel.starts_with(".") || rel.ends_with("~tmp") { continue; }
|
||||
|
||||
let content = fs::read(path).map_err(|e| e.to_string())?;
|
||||
zip.start_file(&rel, options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(&content).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
Ok(format!("Exported to {}", output_path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_folder(vault_path: String, source_path: String) -> Result<u32, String> {
|
||||
let vault = Path::new(&vault_path);
|
||||
let source = Path::new(&source_path);
|
||||
let mut count: u32 = 0;
|
||||
|
||||
for entry in WalkDir::new(source)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
|
||||
{
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(source).unwrap_or(path);
|
||||
let dest = vault.join(rel);
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::copy(path, &dest).map_err(|e| e.to_string())?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
80
src-tauri/src/git.rs
Normal file
80
src-tauri/src/git.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use std::process::Command;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_status(vault_path: String) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
let add_output = Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !add_output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&add_output.stderr).to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["commit", "-m", &message])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(vault_path: String) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["pull"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["push"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_init(vault_path: String) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
1490
src-tauri/src/notes.rs
Normal file
File diff suppressed because it is too large
Load diff
117
src-tauri/src/srs.rs
Normal file
117
src-tauri/src/srs.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{atomic_write, FLASHCARD_RE};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Flashcard {
|
||||
pub question: String,
|
||||
pub answer: String,
|
||||
pub source_path: String,
|
||||
pub line_number: usize,
|
||||
pub due: Option<String>,
|
||||
pub interval: u32,
|
||||
pub ease: f32,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_flashcards(vault_path: String) -> Result<Vec<Flashcard>, String> {
|
||||
let vault = Path::new(&vault_path);
|
||||
let mut cards: Vec<Flashcard> = Vec::new();
|
||||
|
||||
// Load schedule data
|
||||
let srs_path = vault.join(".graph-notes").join("srs.json");
|
||||
let srs: serde_json::Map<String, serde_json::Value> = if srs_path.exists() {
|
||||
let c = fs::read_to_string(&srs_path).unwrap_or_default();
|
||||
serde_json::from_str(&c).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
|
||||
for entry in WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
|
||||
{
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(vault).unwrap_or(path).to_string_lossy().to_string();
|
||||
if rel.starts_with(".") || rel.starts_with("_") { continue; }
|
||||
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
for caps in FLASHCARD_RE.captures_iter(line) {
|
||||
let q = caps[1].trim().to_string();
|
||||
let a = caps[2].trim().to_string();
|
||||
let card_id = format!("{}:{}", rel, i + 1);
|
||||
|
||||
let (due, interval, ease) = if let Some(sched) = srs.get(&card_id) {
|
||||
(
|
||||
sched.get("due").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
sched.get("interval").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
|
||||
sched.get("ease").and_then(|v| v.as_f64()).unwrap_or(2.5) as f32,
|
||||
)
|
||||
} else {
|
||||
(None, 1, 2.5)
|
||||
};
|
||||
|
||||
cards.push(Flashcard {
|
||||
question: q,
|
||||
answer: a,
|
||||
source_path: rel.clone(),
|
||||
line_number: i + 1,
|
||||
due,
|
||||
interval,
|
||||
ease,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(cards)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_card_schedule(
|
||||
vault_path: String,
|
||||
card_id: String,
|
||||
quality: u32,
|
||||
) -> Result<(), String> {
|
||||
let srs_path = Path::new(&vault_path).join(".graph-notes").join("srs.json");
|
||||
fs::create_dir_all(Path::new(&vault_path).join(".graph-notes")).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut srs: serde_json::Map<String, serde_json::Value> = if srs_path.exists() {
|
||||
let c = fs::read_to_string(&srs_path).unwrap_or_default();
|
||||
serde_json::from_str(&c).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
|
||||
let entry = srs.entry(card_id).or_insert_with(|| serde_json::json!({"interval": 1, "ease": 2.5}));
|
||||
let obj = entry.as_object_mut().ok_or("Invalid SRS entry")?;
|
||||
|
||||
let mut interval = obj.get("interval").and_then(|v| v.as_u64()).unwrap_or(1) as f64;
|
||||
let mut ease = obj.get("ease").and_then(|v| v.as_f64()).unwrap_or(2.5);
|
||||
|
||||
// SM-2 algorithm
|
||||
if quality >= 3 {
|
||||
if interval <= 1.0 { interval = 1.0; }
|
||||
else if interval <= 6.0 { interval = 6.0; }
|
||||
else { interval *= ease; }
|
||||
ease = ease + (0.1 - (5.0 - quality as f64) * (0.08 + (5.0 - quality as f64) * 0.02));
|
||||
if ease < 1.3 { ease = 1.3; }
|
||||
} else {
|
||||
interval = 1.0;
|
||||
}
|
||||
|
||||
let due = Local::now() + chrono::Duration::days(interval as i64);
|
||||
obj.insert("interval".into(), serde_json::json!(interval as u32));
|
||||
obj.insert("ease".into(), serde_json::json!(ease));
|
||||
obj.insert("due".into(), serde_json::json!(due.format("%Y-%m-%d").to_string()));
|
||||
|
||||
let json = serde_json::to_string_pretty(&srs).map_err(|e| e.to_string())?;
|
||||
atomic_write(&srs_path, &json)
|
||||
}
|
||||
758
src-tauri/src/state.rs
Normal file
758
src-tauri/src/state.rs
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{atomic_write, safe_name, safe_vault_path, dirs_config_dir, dirs_config_path};
|
||||
|
||||
/* ── Snapshots (Version History) ────────────────────────── */
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SnapshotInfo {
|
||||
pub timestamp: String,
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_snapshot(vault_path: String, note_path: String) -> Result<String, String> {
|
||||
let full = safe_vault_path(&vault_path, ¬e_path)?;
|
||||
let content = fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||
|
||||
let sanitized_name = note_path.replace('/', "__").replace(".md", "");
|
||||
let history_dir = Path::new(&vault_path).join(".graph-notes").join("history").join(&sanitized_name);
|
||||
fs::create_dir_all(&history_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let ts = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let snap_name = format!("{}.md", ts);
|
||||
// Snapshots are write-once, never overwritten — direct write is safe
|
||||
fs::write(history_dir.join(&snap_name), &content).map_err(|e| e.to_string())?;
|
||||
Ok(snap_name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_snapshots(vault_path: String, note_path: String) -> Result<Vec<SnapshotInfo>, String> {
|
||||
let sanitized_name = note_path.replace('/', "__").replace(".md", "");
|
||||
let history_dir = Path::new(&vault_path).join(".graph-notes").join("history").join(&sanitized_name);
|
||||
|
||||
if !history_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut snaps: Vec<SnapshotInfo> = Vec::new();
|
||||
for entry in fs::read_dir(&history_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let meta = entry.metadata().map_err(|e| e.to_string())?;
|
||||
if meta.is_file() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let ts = name.replace(".md", "");
|
||||
snaps.push(SnapshotInfo {
|
||||
timestamp: ts,
|
||||
filename: name,
|
||||
size: meta.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
snaps.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(snaps)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_snapshot(vault_path: String, note_path: String, snapshot_name: String) -> Result<String, String> {
|
||||
let sanitized_name = note_path.replace('/', "__").replace(".md", "");
|
||||
let snap_path = Path::new(&vault_path)
|
||||
.join(".graph-notes")
|
||||
.join("history")
|
||||
.join(&sanitized_name)
|
||||
.join(&snapshot_name);
|
||||
fs::read_to_string(&snap_path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/* ── Search & Replace ───────────────────────────────────── */
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ReplaceResult {
|
||||
pub path: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_replace_vault(
|
||||
vault_path: String,
|
||||
search: String,
|
||||
replace: String,
|
||||
dry_run: bool,
|
||||
) -> Result<Vec<ReplaceResult>, String> {
|
||||
use walkdir::WalkDir;
|
||||
let vault = Path::new(&vault_path);
|
||||
let mut results: Vec<ReplaceResult> = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
|
||||
{
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(vault).unwrap_or(path).to_string_lossy().to_string();
|
||||
if rel.starts_with(".") || rel.starts_with("_") { continue; }
|
||||
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let count = content.matches(&search).count();
|
||||
if count > 0 {
|
||||
results.push(ReplaceResult { path: rel, count });
|
||||
if !dry_run {
|
||||
let updated = content.replace(&search, &replace);
|
||||
crate::atomic_write(path, &updated)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/* ── Writing Goals ──────────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_writing_goal(vault_path: String, note_path: String) -> Result<u32, String> {
|
||||
let goals_path = Path::new(&vault_path).join(".graph-notes").join("goals.json");
|
||||
if !goals_path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let content = fs::read_to_string(&goals_path).map_err(|e| e.to_string())?;
|
||||
let goals: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(goals
|
||||
.get(¬e_path)
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_writing_goal(vault_path: String, note_path: String, goal: u32) -> Result<(), String> {
|
||||
let dir = Path::new(&vault_path).join(".graph-notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let goals_path = dir.join("goals.json");
|
||||
|
||||
let mut goals: serde_json::Map<String, serde_json::Value> = if goals_path.exists() {
|
||||
let c = fs::read_to_string(&goals_path).unwrap_or_default();
|
||||
serde_json::from_str(&c).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
|
||||
goals.insert(note_path, serde_json::json!(goal));
|
||||
let json = serde_json::to_string_pretty(&goals).map_err(|e| e.to_string())?;
|
||||
crate::atomic_write(&goals_path, &json)
|
||||
}
|
||||
|
||||
/* ── Fold State ─────────────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_fold_state(vault_path: String, note_path: String, folds: Vec<usize>) -> Result<(), String> {
|
||||
let dir = Path::new(&vault_path).join(".graph-notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let folds_path = dir.join("folds.json");
|
||||
|
||||
let mut data: serde_json::Map<String, serde_json::Value> = if folds_path.exists() {
|
||||
let c = fs::read_to_string(&folds_path).unwrap_or_default();
|
||||
serde_json::from_str(&c).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
|
||||
data.insert(note_path, serde_json::json!(folds));
|
||||
let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?;
|
||||
crate::atomic_write(&folds_path, &json)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_fold_state(vault_path: String, note_path: String) -> Result<Vec<usize>, String> {
|
||||
let folds_path = Path::new(&vault_path).join(".graph-notes").join("folds.json");
|
||||
if !folds_path.exists() { return Ok(vec![]); }
|
||||
let c = fs::read_to_string(&folds_path).map_err(|e| e.to_string())?;
|
||||
let data: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&c).unwrap_or_default();
|
||||
let folds = data.get(¬e_path)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_u64().map(|n| n as usize)).collect())
|
||||
.unwrap_or_default();
|
||||
Ok(folds)
|
||||
}
|
||||
|
||||
/* ── Custom CSS ─────────────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_custom_css() -> Result<String, String> {
|
||||
let config_dir = dirs_config_dir();
|
||||
let css_path = config_dir.join("custom.css");
|
||||
if css_path.exists() {
|
||||
fs::read_to_string(&css_path).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_custom_css(css: String) -> Result<(), String> {
|
||||
crate::atomic_write(&dirs_config_dir().join("custom.css"), &css)
|
||||
}
|
||||
|
||||
/* ── Workspace Layouts ──────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_workspace(vault_path: String, name: String, state: String) -> Result<(), String> {
|
||||
let sanitized = safe_name(&name)?;
|
||||
let dir = Path::new(&vault_path).join(".graph-notes").join("workspaces");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
crate::atomic_write(&dir.join(format!("{}.json", sanitized)), &state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_workspace(vault_path: String, name: String) -> Result<String, String> {
|
||||
let sanitized = safe_name(&name)?;
|
||||
let path = Path::new(&vault_path).join(".graph-notes").join("workspaces").join(format!("{}.json", sanitized));
|
||||
fs::read_to_string(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_workspaces(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let dir = Path::new(&vault_path).join(".graph-notes").join("workspaces");
|
||||
if !dir.exists() { return Ok(vec![]); }
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().replace(".json", "");
|
||||
names.push(name);
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/* ── Tab Persistence ────────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_tabs(vault_path: String, tabs: String) -> Result<(), String> {
|
||||
let dir = Path::new(&vault_path).join(".graph-notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
crate::atomic_write(&dir.join("tabs.json"), &tabs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_tabs(vault_path: String) -> Result<String, String> {
|
||||
let path = Path::new(&vault_path).join(".graph-notes").join("tabs.json");
|
||||
if !path.exists() { return Ok("[]".to_string()); }
|
||||
fs::read_to_string(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/* ── Canvas / Whiteboard Persistence ────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_canvas(vault_path: String, name: String, data: String) -> Result<(), String> {
|
||||
let sanitized = safe_name(&name)?;
|
||||
let dir = Path::new(&vault_path).join(".graph-notes").join("canvases");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
crate::atomic_write(&dir.join(format!("{}.json", sanitized)), &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_canvas(vault_path: String, name: String) -> Result<String, String> {
|
||||
let sanitized = safe_name(&name)?;
|
||||
let path = Path::new(&vault_path).join(".graph-notes").join("canvases").join(format!("{}.json", sanitized));
|
||||
if !path.exists() { return Ok("{}".to_string()); }
|
||||
fs::read_to_string(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_canvases(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let dir = Path::new(&vault_path).join(".graph-notes").join("canvases");
|
||||
if !dir.exists() { return Ok(vec![]); }
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in fs::read_dir(&dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().replace(".json", "");
|
||||
names.push(name);
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/* ── Shortcuts ──────────────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_shortcuts(vault_path: String, shortcuts_json: String) -> Result<(), String> {
|
||||
let dir = Path::new(&vault_path).join(".graph-notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
crate::atomic_write(&dir.join("shortcuts.json"), &shortcuts_json)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_shortcuts(vault_path: String) -> Result<String, String> {
|
||||
let path = Path::new(&vault_path).join(".graph-notes/shortcuts.json");
|
||||
if path.exists() {
|
||||
fs::read_to_string(path).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok("{}".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pinned Notes ───────────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_pinned(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let path = Path::new(&vault_path).join(".graph-notes/pinned.json");
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&content).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_pinned(vault_path: String, pinned: Vec<String>) -> Result<(), String> {
|
||||
let dir = Path::new(&vault_path).join(".graph-notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let json = serde_json::to_string_pretty(&pinned).map_err(|e| e.to_string())?;
|
||||
crate::atomic_write(&dir.join("pinned.json"), &json)
|
||||
}
|
||||
|
||||
/* ── Theme ──────────────────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_theme() -> Result<String, String> {
|
||||
let path = dirs_config_dir().join("theme");
|
||||
if path.exists() {
|
||||
fs::read_to_string(&path).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok("dark-purple".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_theme(theme: String) -> Result<(), String> {
|
||||
crate::atomic_write(&dirs_config_dir().join("theme"), &theme)
|
||||
}
|
||||
|
||||
/* ── Favorites ──────────────────────────────────────────── */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_favorites(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let path = Path::new(&vault_path).join(".graph-notes/favorites.json");
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&content).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_favorites(vault_path: String, favorites: Vec<String>) -> Result<(), String> {
|
||||
let dir = Path::new(&vault_path).join(".graph-notes");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let json = serde_json::to_string_pretty(&favorites).map_err(|e| e.to_string())?;
|
||||
crate::atomic_write(&dir.join("favorites.json"), &json)
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
v1.3 — Reading List & Progress Tracker
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_reading_list(vault_path: String) -> Result<String, String> {
|
||||
let rl_path = Path::new(&vault_path).join(".graph-notes").join("reading-list.json");
|
||||
if rl_path.exists() {
|
||||
fs::read_to_string(&rl_path).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok("[]".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_reading_list(vault_path: String, data: String) -> Result<(), String> {
|
||||
let gn_dir = Path::new(&vault_path).join(".graph-notes");
|
||||
fs::create_dir_all(&gn_dir).map_err(|e| e.to_string())?;
|
||||
let rl_path = gn_dir.join("reading-list.json");
|
||||
atomic_write(&rl_path, &data)
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
v1.3 — Plugin / Hook System
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
||||
pub struct PluginInfo {
|
||||
pub name: String,
|
||||
pub filename: String,
|
||||
pub enabled: bool,
|
||||
pub hooks: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_plugins(vault_path: String) -> Result<Vec<PluginInfo>, String> {
|
||||
let plugins_dir = Path::new(&vault_path).join(".graph-notes").join("plugins");
|
||||
if !plugins_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut plugins = Vec::new();
|
||||
// Check for manifest
|
||||
let manifest_path = plugins_dir.join("manifest.json");
|
||||
let manifest: serde_json::Map<String, serde_json::Value> = if manifest_path.exists() {
|
||||
let data = fs::read_to_string(&manifest_path).unwrap_or_default();
|
||||
serde_json::from_str(&data).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
|
||||
for entry in fs::read_dir(&plugins_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let filename = entry.file_name().to_string_lossy().to_string();
|
||||
if !filename.ends_with(".js") { continue; }
|
||||
|
||||
let name = filename.replace(".js", "");
|
||||
let enabled = manifest.get(&name)
|
||||
.and_then(|v| v.get("enabled"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
// Scan for hook registrations
|
||||
let content = fs::read_to_string(entry.path()).unwrap_or_default();
|
||||
let mut hooks = Vec::new();
|
||||
for hook in &["on_save", "on_create", "on_delete", "on_daily"] {
|
||||
if content.contains(hook) {
|
||||
hooks.push(hook.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
plugins.push(PluginInfo { name, filename, enabled, hooks });
|
||||
}
|
||||
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_plugin(vault_path: String, name: String, enabled: bool) -> Result<(), String> {
|
||||
let plugins_dir = Path::new(&vault_path).join(".graph-notes").join("plugins");
|
||||
fs::create_dir_all(&plugins_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let manifest_path = plugins_dir.join("manifest.json");
|
||||
let mut manifest: serde_json::Map<String, serde_json::Value> = if manifest_path.exists() {
|
||||
let data = fs::read_to_string(&manifest_path).unwrap_or_default();
|
||||
serde_json::from_str(&data).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
|
||||
let entry = manifest.entry(name).or_insert_with(|| serde_json::json!({}));
|
||||
if let Some(obj) = entry.as_object_mut() {
|
||||
obj.insert("enabled".into(), serde_json::Value::Bool(enabled));
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
||||
atomic_write(&manifest_path, &json)
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
v1.3 — Vault Registry (for Federated Search)
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_registry() -> Result<Vec<String>, String> {
|
||||
let config_path = dirs_config_dir().join("vaults.json");
|
||||
if config_path.exists() {
|
||||
let data = fs::read_to_string(&config_path).map_err(|e| e.to_string())?;
|
||||
let vaults: Vec<String> = serde_json::from_str(&data).unwrap_or_default();
|
||||
Ok(vaults)
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_vault_registry(vaults: Vec<String>) -> Result<(), String> {
|
||||
let config_dir = dirs_config_dir();
|
||||
fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
|
||||
let config_path = config_dir.join("vaults.json");
|
||||
let json = serde_json::to_string_pretty(&vaults).map_err(|e| e.to_string())?;
|
||||
atomic_write(&config_path, &json)
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
v1.4 — Automatic Backup Snapshots
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
||||
pub struct BackupEntry {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_backup(vault_path: String) -> Result<String, String> {
|
||||
let vault = std::path::Path::new(&vault_path);
|
||||
let backup_dir = vault.join(".graph-notes").join("backups");
|
||||
fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S").to_string();
|
||||
let name = format!("backup_{}.zip", ts);
|
||||
let zip_path = backup_dir.join(&name);
|
||||
|
||||
let file = fs::File::create(&zip_path).map_err(|e| e.to_string())?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
for entry in walkdir::WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_file())
|
||||
{
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(vault).unwrap_or(path).to_string_lossy().to_string();
|
||||
// Skip backups dir and tmp files
|
||||
if rel.starts_with(".graph-notes/backups") { continue; }
|
||||
if rel.contains("~tmp") { continue; }
|
||||
|
||||
if let Ok(data) = fs::read(path) {
|
||||
zip.start_file(&rel, options).map_err(|e| e.to_string())?;
|
||||
use std::io::Write;
|
||||
zip.write_all(&data).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
|
||||
// Auto-prune: keep only last 10 backups
|
||||
let mut backups = list_backup_entries(&backup_dir)?;
|
||||
backups.sort_by(|a, b| b.name.cmp(&a.name));
|
||||
for old in backups.iter().skip(10) {
|
||||
let _ = fs::remove_file(backup_dir.join(&old.name));
|
||||
}
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn list_backup_entries(backup_dir: &std::path::Path) -> Result<Vec<BackupEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
if !backup_dir.exists() { return Ok(entries); }
|
||||
|
||||
for entry in fs::read_dir(backup_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".zip") { continue; }
|
||||
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
// Extract timestamp from filename: backup_YYYY-MM-DD_HHMMSS.zip
|
||||
let created = name.replace("backup_", "").replace(".zip", "")
|
||||
.replace('_', " ");
|
||||
|
||||
entries.push(BackupEntry { name, size, created });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_backups(vault_path: String) -> Result<Vec<BackupEntry>, String> {
|
||||
let backup_dir = std::path::Path::new(&vault_path).join(".graph-notes").join("backups");
|
||||
let mut entries = list_backup_entries(&backup_dir)?;
|
||||
entries.sort_by(|a, b| b.name.cmp(&a.name));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_backup(vault_path: String, backup_name: String) -> Result<u32, String> {
|
||||
let vault = std::path::Path::new(&vault_path);
|
||||
let zip_path = vault.join(".graph-notes").join("backups").join(&backup_name);
|
||||
|
||||
if !zip_path.exists() {
|
||||
return Err("Backup not found".into());
|
||||
}
|
||||
|
||||
let file = fs::File::open(&zip_path).map_err(|e| e.to_string())?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
let mut count = 0u32;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
if entry.is_dir() { continue; }
|
||||
|
||||
let name = entry.name().to_string();
|
||||
// Skip restoring backup files themselves
|
||||
if name.starts_with(".graph-notes/backups") { continue; }
|
||||
|
||||
let dest = vault.join(&name);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let mut content = Vec::new();
|
||||
use std::io::Read;
|
||||
entry.read_to_end(&mut content).map_err(|e| e.to_string())?;
|
||||
|
||||
crate::atomic_write_bytes(&dest, &content)?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
v1.4 — Write-Ahead Log (WAL)
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
||||
pub struct WalEntry {
|
||||
pub timestamp: String,
|
||||
pub operation: String, // "write" | "delete" | "rename"
|
||||
pub path: String,
|
||||
pub content_hash: String,
|
||||
pub status: String, // "pending" | "complete"
|
||||
}
|
||||
|
||||
pub fn wal_append_entry(vault_path: &str, operation: &str, path: &str, content_hash: &str) {
|
||||
let wal_path = std::path::Path::new(vault_path).join(".graph-notes").join("wal.log");
|
||||
let _ = fs::create_dir_all(std::path::Path::new(vault_path).join(".graph-notes"));
|
||||
|
||||
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
let line = format!("{}|{}|{}|{}|pending\n", ts, operation, path, content_hash);
|
||||
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&wal_path) {
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wal_mark_complete(vault_path: &str, path: &str) {
|
||||
let wal_path = std::path::Path::new(vault_path).join(".graph-notes").join("wal.log");
|
||||
if !wal_path.exists() { return; }
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&wal_path) {
|
||||
let updated: String = content.lines().map(|line| {
|
||||
if line.contains(path) && line.ends_with("|pending") {
|
||||
format!("{}", line.replace("|pending", "|complete"))
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
}).collect::<Vec<_>>().join("\n");
|
||||
let _ = fs::write(&wal_path, format!("{}\n", updated.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn wal_status(vault_path: String) -> Result<Vec<WalEntry>, String> {
|
||||
let wal_path = std::path::Path::new(&vault_path).join(".graph-notes").join("wal.log");
|
||||
if !wal_path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&wal_path).map_err(|e| e.to_string())?;
|
||||
let entries: Vec<WalEntry> = content.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.splitn(5, '|').collect();
|
||||
if parts.len() == 5 {
|
||||
Some(WalEntry {
|
||||
timestamp: parts[0].to_string(),
|
||||
operation: parts[1].to_string(),
|
||||
path: parts[2].to_string(),
|
||||
content_hash: parts[3].to_string(),
|
||||
status: parts[4].to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn wal_recover(vault_path: String) -> Result<u32, String> {
|
||||
let entries = wal_status(vault_path.clone())?;
|
||||
let pending: Vec<&WalEntry> = entries.iter().filter(|e| e.status == "pending").collect();
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// For pending writes where the file exists and hash matches, mark complete
|
||||
let vault = std::path::Path::new(&vault_path);
|
||||
let mut recovered = 0u32;
|
||||
|
||||
for entry in &pending {
|
||||
let full = vault.join(&entry.path);
|
||||
if full.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&full) {
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
if hash == entry.content_hash {
|
||||
wal_mark_complete(&vault_path, &entry.path);
|
||||
recovered += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
v1.4 — File Operation Audit Log
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
|
||||
pub struct AuditEntry {
|
||||
pub timestamp: String,
|
||||
pub operation: String,
|
||||
pub path: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
pub fn audit_log_append(vault_path: &str, operation: &str, path: &str, detail: &str) {
|
||||
let log_path = std::path::Path::new(vault_path).join(".graph-notes").join("audit.log");
|
||||
let _ = fs::create_dir_all(std::path::Path::new(vault_path).join(".graph-notes"));
|
||||
|
||||
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
let line = format!("{}|{}|{}|{}\n", ts, operation, path, detail);
|
||||
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_audit_log(vault_path: String, limit: usize) -> Result<Vec<AuditEntry>, String> {
|
||||
let log_path = std::path::Path::new(&vault_path).join(".graph-notes").join("audit.log");
|
||||
if !log_path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&log_path).map_err(|e| e.to_string())?;
|
||||
let mut entries: Vec<AuditEntry> = content.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() == 4 {
|
||||
Some(AuditEntry {
|
||||
timestamp: parts[0].to_string(),
|
||||
operation: parts[1].to_string(),
|
||||
path: parts[2].to_string(),
|
||||
detail: parts[3].to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Return most recent first, limited
|
||||
entries.reverse();
|
||||
entries.truncate(limit);
|
||||
Ok(entries)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"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": {
|
||||
|
|
|
|||
277
src/App.tsx
277
src/App.tsx
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
85
src/components/AuditLog.tsx
Normal file
85
src/components/AuditLog.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useVault } from "../App";
|
||||
import { getAuditLog, type AuditEntry } from "../lib/commands";
|
||||
|
||||
export default function AuditLog({ onClose }: { onClose: () => void }) {
|
||||
const { vaultPath } = useVault();
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadLog();
|
||||
}, [vaultPath]);
|
||||
|
||||
const loadLog = async () => {
|
||||
if (!vaultPath) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const log = await getAuditLog(vaultPath, 200);
|
||||
setEntries(log);
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const filtered = filter
|
||||
? entries.filter(e =>
|
||||
e.path.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
e.operation.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
e.detail.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
: entries;
|
||||
|
||||
const opIcon = (op: string) => {
|
||||
switch (op) {
|
||||
case "create": return "🆕";
|
||||
case "update": return "✏️";
|
||||
case "delete": return "🗑️";
|
||||
case "rename": return "📝";
|
||||
case "move": return "📁";
|
||||
default: return "📄";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="audit-log">
|
||||
<div className="audit-header">
|
||||
<h3>📋 Audit Log</h3>
|
||||
<button className="panel-close-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="audit-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by path, operation, or detail..."
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="audit-filter-input"
|
||||
/>
|
||||
<button className="audit-refresh-btn" onClick={loadLog} disabled={loading}>⟳</button>
|
||||
</div>
|
||||
|
||||
<div className="audit-body">
|
||||
{loading && <div className="audit-loading">Loading audit log...</div>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="audit-empty">No log entries{filter ? " matching filter" : ""}</div>
|
||||
)}
|
||||
{filtered.map((entry, i) => (
|
||||
<div key={i} className="audit-entry">
|
||||
<span className="audit-op-icon">{opIcon(entry.operation)}</span>
|
||||
<div className="audit-entry-body">
|
||||
<div className="audit-entry-top">
|
||||
<span className="audit-op-badge">{entry.operation}</span>
|
||||
<span className="audit-path">{entry.path}</span>
|
||||
</div>
|
||||
<div className="audit-entry-bottom">
|
||||
<span className="audit-time">{entry.timestamp}</span>
|
||||
{entry.detail && <span className="audit-detail">{entry.detail}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/BookmarksPanel.tsx
Normal file
79
src/components/BookmarksPanel.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { getBookmarks, setBookmarks, type Bookmark } from '../lib/commands';
|
||||
|
||||
interface BookmarksPanelProps {
|
||||
vaultPath: string;
|
||||
onNavigate: (notePath: string, line?: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BookmarksPanel({ vaultPath, onNavigate, onClose }: BookmarksPanelProps) {
|
||||
const [bookmarks, setBookmarksList] = useState<Bookmark[]>([]);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const bm = await getBookmarks(vaultPath);
|
||||
setBookmarksList(bm);
|
||||
} catch (e) {
|
||||
console.error('Failed to load bookmarks:', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { refresh(); }, [vaultPath]);
|
||||
|
||||
const handleRemove = async (index: number) => {
|
||||
const updated = bookmarks.filter((_, i) => i !== index);
|
||||
try {
|
||||
await setBookmarks(vaultPath, JSON.stringify(updated));
|
||||
setBookmarksList(updated);
|
||||
} catch (e) {
|
||||
console.error('Failed to update bookmarks:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (bm: Bookmark) => {
|
||||
const notePath = bm.note_path.endsWith('.md') ? bm.note_path : `${bm.note_path}.md`;
|
||||
onNavigate(notePath, bm.line);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bookmarks-panel" id="bookmarks-panel">
|
||||
<div className="bookmarks-panel-header">
|
||||
<h3>🔖 Bookmarks</h3>
|
||||
<button className="panel-close-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="bookmarks-panel-body">
|
||||
{bookmarks.length === 0 ? (
|
||||
<div className="bookmarks-empty-state">
|
||||
No bookmarks yet. Right-click a line in the editor to add one.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="bookmarks-list">
|
||||
{bookmarks.map((bm, i) => (
|
||||
<li key={`${bm.note_path}-${bm.line}-${i}`} className="bookmark-item">
|
||||
<button
|
||||
className="bookmark-link"
|
||||
onClick={() => handleClick(bm)}
|
||||
title={`${bm.note_path}:${bm.line}`}
|
||||
>
|
||||
<span className="bookmark-label">{bm.label || bm.note_path.replace('.md', '')}</span>
|
||||
<span className="bookmark-meta">
|
||||
{bm.note_path.replace('.md', '')} · L{bm.line}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="bookmark-remove-btn"
|
||||
onClick={() => handleRemove(i)}
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/CSSEditor.tsx
Normal file
83
src/components/CSSEditor.tsx
Normal 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(() => { });
|
||||
}, []);
|
||||
}
|
||||
122
src/components/CalendarView.tsx
Normal file
122
src/components/CalendarView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
src/components/CommandPalette.tsx
Normal file
322
src/components/CommandPalette.tsx
Normal 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;
|
||||
}
|
||||
75
src/components/ContextMenu.tsx
Normal file
75
src/components/ContextMenu.tsx
Normal 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 };
|
||||
}
|
||||
171
src/components/DatabaseView.tsx
Normal file
171
src/components/DatabaseView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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, '"');
|
||||
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 = ``;
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
// Process line by line for heading detection
|
||||
const lines = raw.split("\n");
|
||||
const htmlLines = lines.map(line => {
|
||||
// Escape HTML
|
||||
let escaped = line
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
// Replace [[wikilinks]] with token spans
|
||||
escaped = escaped.replace(
|
||||
/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
|
||||
(_m, target, display) => {
|
||||
const label = display?.trim() || target.trim();
|
||||
const rawAttr = _m.replace(/"/g, """);
|
||||
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, """);
|
||||
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 */
|
||||
|
|
|
|||
105
src/components/FlashcardView.tsx
Normal file
105
src/components/FlashcardView.tsx
Normal 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
160
src/components/GitPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
src/components/GraphAnalytics.tsx
Normal file
195
src/components/GraphAnalytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
138
src/components/HistoryPanel.tsx
Normal file
138
src/components/HistoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/components/ImportExport.tsx
Normal file
106
src/components/ImportExport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
src/components/IntegrityReport.tsx
Normal file
232
src/components/IntegrityReport.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useVault } from "../App";
|
||||
import {
|
||||
scanIntegrity,
|
||||
computeChecksums,
|
||||
verifyChecksums,
|
||||
findOrphanAttachments,
|
||||
createBackup,
|
||||
listBackups,
|
||||
restoreBackup,
|
||||
type IntegrityIssue,
|
||||
type ChecksumMismatch,
|
||||
type OrphanAttachment,
|
||||
type BackupEntry,
|
||||
} from "../lib/commands";
|
||||
|
||||
export default function IntegrityReport({ onClose }: { onClose: () => void }) {
|
||||
const { vaultPath } = useVault();
|
||||
const [issues, setIssues] = useState<IntegrityIssue[]>([]);
|
||||
const [mismatches, setMismatches] = useState<ChecksumMismatch[]>([]);
|
||||
const [orphans, setOrphans] = useState<OrphanAttachment[]>([]);
|
||||
const [backups, setBackups] = useState<BackupEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"scan" | "checksums" | "orphans" | "backups">("scan");
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
runScan();
|
||||
loadBackups();
|
||||
}, [vaultPath]);
|
||||
|
||||
const runScan = async () => {
|
||||
if (!vaultPath) return;
|
||||
setLoading(true);
|
||||
setStatus("Scanning vault...");
|
||||
try {
|
||||
const result = await scanIntegrity(vaultPath);
|
||||
setIssues(result);
|
||||
setStatus(`Found ${result.length} issue(s)`);
|
||||
} catch (e) {
|
||||
setStatus(`Scan failed: ${e}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const runChecksumVerify = async () => {
|
||||
if (!vaultPath) return;
|
||||
setLoading(true);
|
||||
setStatus("Computing checksums...");
|
||||
try {
|
||||
await computeChecksums(vaultPath);
|
||||
const result = await verifyChecksums(vaultPath);
|
||||
setMismatches(result);
|
||||
setStatus(result.length === 0 ? "All checksums valid ✓" : `${result.length} mismatch(es) found`);
|
||||
} catch (e) {
|
||||
setStatus(`Checksum verification failed: ${e}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const runOrphanScan = async () => {
|
||||
if (!vaultPath) return;
|
||||
setLoading(true);
|
||||
setStatus("Scanning attachments...");
|
||||
try {
|
||||
const result = await findOrphanAttachments(vaultPath);
|
||||
setOrphans(result);
|
||||
setStatus(result.length === 0 ? "No orphan attachments ✓" : `${result.length} orphan(s) found`);
|
||||
} catch (e) {
|
||||
setStatus(`Orphan scan failed: ${e}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
if (!vaultPath) return;
|
||||
setLoading(true);
|
||||
setStatus("Creating backup...");
|
||||
try {
|
||||
const name = await createBackup(vaultPath);
|
||||
setStatus(`Backup created: ${name}`);
|
||||
loadBackups();
|
||||
} catch (e) {
|
||||
setStatus(`Backup failed: ${e}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadBackups = async () => {
|
||||
if (!vaultPath) return;
|
||||
try {
|
||||
const list = await listBackups(vaultPath);
|
||||
setBackups(list);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleRestore = async (name: string) => {
|
||||
if (!vaultPath) return;
|
||||
if (!confirm(`Restore from ${name}? This will overwrite current files.`)) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const count = await restoreBackup(vaultPath, name);
|
||||
setStatus(`Restored ${count} files from ${name}`);
|
||||
} catch (e) {
|
||||
setStatus(`Restore failed: ${e}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const severityIcon = (s: string) => s === "error" ? "🔴" : s === "warning" ? "🟡" : "🔵";
|
||||
|
||||
return (
|
||||
<div className="integrity-report">
|
||||
<div className="integrity-header">
|
||||
<h3>🛡️ Integrity Report</h3>
|
||||
<button className="panel-close-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="integrity-tabs">
|
||||
{(["scan", "checksums", "orphans", "backups"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`integrity-tab ${activeTab === tab ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setActiveTab(tab);
|
||||
if (tab === "checksums") runChecksumVerify();
|
||||
if (tab === "orphans") runOrphanScan();
|
||||
}}
|
||||
>
|
||||
{tab === "scan" ? "🔍 Scan" : tab === "checksums" ? "🔐 Checksums"
|
||||
: tab === "orphans" ? "📎 Orphans" : "💾 Backups"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{status && <div className="integrity-status">{loading ? "⏳ " : ""}{status}</div>}
|
||||
|
||||
<div className="integrity-body">
|
||||
{activeTab === "scan" && (
|
||||
<>
|
||||
<button className="integrity-action-btn" onClick={runScan} disabled={loading}>Re-scan Vault</button>
|
||||
{issues.length === 0 && !loading && (
|
||||
<div className="integrity-empty">✅ No issues found — vault is clean</div>
|
||||
)}
|
||||
{issues.map((issue, i) => (
|
||||
<div key={i} className="integrity-issue">
|
||||
<span className="integrity-severity">{severityIcon(issue.severity)}</span>
|
||||
<div className="integrity-issue-body">
|
||||
<span className="integrity-issue-path">{issue.path}</span>
|
||||
<span className="integrity-issue-desc">{issue.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "checksums" && (
|
||||
<>
|
||||
<button className="integrity-action-btn" onClick={runChecksumVerify} disabled={loading}>
|
||||
Recompute & Verify
|
||||
</button>
|
||||
{mismatches.length === 0 && !loading && (
|
||||
<div className="integrity-empty">✅ All checksums match</div>
|
||||
)}
|
||||
{mismatches.map((m, i) => (
|
||||
<div key={i} className="integrity-issue">
|
||||
<span className="integrity-severity">⚠️</span>
|
||||
<div className="integrity-issue-body">
|
||||
<span className="integrity-issue-path">{m.path}</span>
|
||||
<span className="integrity-issue-desc">
|
||||
Expected: {m.expected.slice(0, 12)}… → Got: {m.actual.slice(0, 12)}…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "orphans" && (
|
||||
<>
|
||||
<button className="integrity-action-btn" onClick={runOrphanScan} disabled={loading}>
|
||||
Rescan Orphans
|
||||
</button>
|
||||
{orphans.length === 0 && !loading && (
|
||||
<div className="integrity-empty">✅ No orphan attachments</div>
|
||||
)}
|
||||
{orphans.map((o, i) => (
|
||||
<div key={i} className="integrity-issue">
|
||||
<span className="integrity-severity">📎</span>
|
||||
<div className="integrity-issue-body">
|
||||
<span className="integrity-issue-path">{o.path}</span>
|
||||
<span className="integrity-issue-desc">{formatSize(o.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "backups" && (
|
||||
<>
|
||||
<button className="integrity-action-btn" onClick={handleCreateBackup} disabled={loading}>
|
||||
Create Backup Now
|
||||
</button>
|
||||
{backups.length === 0 && !loading && (
|
||||
<div className="integrity-empty">No backups yet</div>
|
||||
)}
|
||||
{backups.map((b, i) => (
|
||||
<div key={i} className="integrity-issue">
|
||||
<span className="integrity-severity">💾</span>
|
||||
<div className="integrity-issue-body">
|
||||
<span className="integrity-issue-path">{b.name}</span>
|
||||
<span className="integrity-issue-desc">
|
||||
{b.created} · {formatSize(b.size)}
|
||||
</span>
|
||||
</div>
|
||||
<button className="integrity-restore-btn" onClick={() => handleRestore(b.name)}>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/components/KanbanView.tsx
Normal file
115
src/components/KanbanView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/components/LinkPreview.tsx
Normal file
147
src/components/LinkPreview.tsx
Normal 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;
|
||||
}
|
||||
69
src/components/LockScreen.tsx
Normal file
69
src/components/LockScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/components/MiniGraph.tsx
Normal file
133
src/components/MiniGraph.tsx
Normal 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;
|
||||
}
|
||||
43
src/components/NoteGraphNode.tsx
Normal file
43
src/components/NoteGraphNode.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* NoteGraphNode — Custom node component for the note graph.
|
||||
* Shows title, tag pills, link count badge, and cluster color.
|
||||
*/
|
||||
export function NoteGraphNode({ nodeData, isSelected }: { nodeData: any; isSelected?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const meta = nodeData.dbData ?? nodeData.data ?? {};
|
||||
const label = nodeData.label || meta.label || "Untitled";
|
||||
const tags: string[] = meta.tags || [];
|
||||
const linkCount: number = meta.link_count ?? 0;
|
||||
const color: string = meta.color || "#8b5cf6";
|
||||
const path = meta.path || "";
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (path) navigate(`/note/${encodeURIComponent(path)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`note-graph-node ${isSelected ? "selected" : ""}`}
|
||||
style={{ "--node-color": color } as React.CSSProperties}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div className="note-graph-node-color" style={{ background: color }} />
|
||||
<div className="note-graph-node-body">
|
||||
<span className="note-graph-node-title">{label}</span>
|
||||
{tags.length > 0 && (
|
||||
<div className="note-graph-node-tags">
|
||||
{tags.slice(0, 3).map((t: string) => (
|
||||
<span key={t} className="note-graph-node-tag">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{linkCount > 0 && (
|
||||
<span className="note-graph-node-badge">{linkCount}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/OutlinePanel.tsx
Normal file
120
src/components/OutlinePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/PropertiesPanel.tsx
Normal file
127
src/components/PropertiesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/QuickCapture.tsx
Normal file
74
src/components/QuickCapture.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { appendToInbox } from '../lib/commands';
|
||||
|
||||
interface QuickCaptureProps {
|
||||
vaultPath: string;
|
||||
onClose: () => void;
|
||||
onCaptured?: () => void;
|
||||
}
|
||||
|
||||
export default function QuickCapture({ vaultPath, onClose, onCaptured }: QuickCaptureProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!content.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await appendToInbox(vaultPath, content);
|
||||
setContent('');
|
||||
onCaptured?.();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to save to inbox:', e);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="quick-capture-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="quick-capture-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
id="quick-capture"
|
||||
>
|
||||
<div className="quick-capture-header">
|
||||
<span className="quick-capture-title">⚡ Quick Capture</span>
|
||||
<span className="quick-capture-hint">⌘↵ to save · Esc to close</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="quick-capture-input"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Jot down a fleeting thought..."
|
||||
rows={4}
|
||||
/>
|
||||
<div className="quick-capture-footer">
|
||||
<span className="quick-capture-dest">→ _inbox.md</span>
|
||||
<button
|
||||
className="quick-capture-save-btn"
|
||||
onClick={handleSave}
|
||||
disabled={!content.trim() || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Capture'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/components/RefactorMenu.tsx
Normal file
104
src/components/RefactorMenu.tsx
Normal 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;
|
||||
}
|
||||
101
src/components/SearchReplace.tsx
Normal file
101
src/components/SearchReplace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/ShortcutsEditor.tsx
Normal file
122
src/components/ShortcutsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
104
src/components/SlashMenu.tsx
Normal file
104
src/components/SlashMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/SplitView.tsx
Normal file
122
src/components/SplitView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/StatusBar.tsx
Normal file
104
src/components/StatusBar.tsx
Normal 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
67
src/components/TabBar.tsx
Normal 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 };
|
||||
192
src/components/TableEditor.tsx
Normal file
192
src/components/TableEditor.tsx
Normal 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");
|
||||
}
|
||||
91
src/components/TableOfContents.tsx
Normal file
91
src/components/TableOfContents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/components/ThemePicker.tsx
Normal file
186
src/components/ThemePicker.tsx
Normal 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(() => { });
|
||||
}, []);
|
||||
}
|
||||
98
src/components/TimelineView.tsx
Normal file
98
src/components/TimelineView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/components/TransclusionBlock.tsx
Normal file
122
src/components/TransclusionBlock.tsx
Normal 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;
|
||||
}
|
||||
100
src/components/TrashPanel.tsx
Normal file
100
src/components/TrashPanel.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { listTrash, restoreNote, emptyTrash, type TrashEntry } from '../lib/commands';
|
||||
|
||||
interface TrashPanelProps {
|
||||
vaultPath: string;
|
||||
onRestore?: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TrashPanel({ vaultPath, onRestore, onClose }: TrashPanelProps) {
|
||||
const [items, setItems] = useState<TrashEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await listTrash(vaultPath);
|
||||
setItems(entries);
|
||||
} catch (e) {
|
||||
console.error('Failed to list trash:', e);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { refresh(); }, [vaultPath]);
|
||||
|
||||
const handleRestore = async (trashedName: string) => {
|
||||
try {
|
||||
const restoredPath = await restoreNote(vaultPath, trashedName);
|
||||
onRestore?.(restoredPath);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to restore:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmptyTrash = async () => {
|
||||
if (!confirm('Permanently delete all trashed notes? This cannot be undone.')) return;
|
||||
try {
|
||||
await emptyTrash(vaultPath);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to empty trash:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (ts: string) => {
|
||||
if (ts.length < 15) return ts;
|
||||
return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(9, 11)}:${ts.slice(11, 13)}`;
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="trash-panel" id="trash-panel">
|
||||
<div className="trash-panel-header">
|
||||
<h3>🗑️ Trash</h3>
|
||||
<div className="trash-panel-actions">
|
||||
{items.length > 0 && (
|
||||
<button className="trash-empty-btn" onClick={handleEmptyTrash} title="Empty Trash">
|
||||
Empty
|
||||
</button>
|
||||
)}
|
||||
<button className="panel-close-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="trash-panel-body">
|
||||
{loading ? (
|
||||
<div className="trash-empty-state">Loading...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="trash-empty-state">Trash is empty</div>
|
||||
) : (
|
||||
<ul className="trash-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.trashed_name} className="trash-item">
|
||||
<div className="trash-item-info">
|
||||
<span className="trash-item-name">{item.original_path.replace('.md', '')}</span>
|
||||
<span className="trash-item-meta">
|
||||
{formatTimestamp(item.timestamp)} · {formatSize(item.size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="trash-restore-btn"
|
||||
onClick={() => handleRestore(item.trashed_name)}
|
||||
title="Restore"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/WhiteboardView.tsx
Normal file
63
src/components/WhiteboardView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5278
src/index.css
5278
src/index.css
File diff suppressed because it is too large
Load diff
145
src/lib/clustering.ts
Normal file
145
src/lib/clustering.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Simple Louvain-style community detection for graph clustering.
|
||||
* Operates on the GraphData structure from commands.ts.
|
||||
*/
|
||||
|
||||
import type { GraphData } from './commands';
|
||||
|
||||
export interface ClusterResult {
|
||||
/** Map from node ID → cluster index */
|
||||
assignments: Map<string, number>;
|
||||
/** Number of clusters found */
|
||||
clusterCount: number;
|
||||
/** Cluster index → array of node IDs */
|
||||
clusters: Map<number, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect communities using a simplified Louvain method.
|
||||
* Uses modularity optimization via greedy local moves.
|
||||
*/
|
||||
export function detectClusters(graph: GraphData): ClusterResult {
|
||||
const nodeIds = graph.nodes.map(n => n.id);
|
||||
const nodeIndex = new Map<string, number>();
|
||||
nodeIds.forEach((id, i) => nodeIndex.set(id, i));
|
||||
|
||||
// Build adjacency list
|
||||
const adj = new Map<number, Set<number>>();
|
||||
for (let i = 0; i < nodeIds.length; i++) {
|
||||
adj.set(i, new Set());
|
||||
}
|
||||
for (const edge of graph.edges) {
|
||||
const s = nodeIndex.get(edge.source);
|
||||
const t = nodeIndex.get(edge.target);
|
||||
if (s !== undefined && t !== undefined) {
|
||||
adj.get(s)!.add(t);
|
||||
adj.get(t)!.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
const n = nodeIds.length;
|
||||
if (n === 0) {
|
||||
return { assignments: new Map(), clusterCount: 0, clusters: new Map() };
|
||||
}
|
||||
|
||||
// Initialize: each node is its own community
|
||||
const community = new Array<number>(n);
|
||||
for (let i = 0; i < n; i++) community[i] = i;
|
||||
|
||||
const totalEdges = graph.edges.length || 1;
|
||||
const m2 = totalEdges * 2;
|
||||
|
||||
// Degree of each node
|
||||
const degree = new Array<number>(n).fill(0);
|
||||
for (let i = 0; i < n; i++) {
|
||||
degree[i] = adj.get(i)!.size;
|
||||
}
|
||||
|
||||
// Sum of degrees for each community
|
||||
const sigmaTot = new Array<number>(n).fill(0);
|
||||
for (let i = 0; i < n; i++) sigmaTot[i] = degree[i];
|
||||
|
||||
// Iterate greedy local moves
|
||||
let improved = true;
|
||||
let iterations = 0;
|
||||
const maxIterations = 20;
|
||||
|
||||
while (improved && iterations < maxIterations) {
|
||||
improved = false;
|
||||
iterations++;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const currentComm = community[i];
|
||||
const neighbors = adj.get(i)!;
|
||||
const ki = degree[i];
|
||||
|
||||
// Count edges to each neighboring community
|
||||
const commEdges = new Map<number, number>();
|
||||
for (const neighbor of neighbors) {
|
||||
const nc = community[neighbor];
|
||||
commEdges.set(nc, (commEdges.get(nc) || 0) + 1);
|
||||
}
|
||||
|
||||
// Modularity gain of removing node from current community
|
||||
const kiIn = commEdges.get(currentComm) || 0;
|
||||
sigmaTot[currentComm] -= ki;
|
||||
|
||||
let bestComm = currentComm;
|
||||
let bestGain = 0;
|
||||
|
||||
for (const [comm, edgesToComm] of commEdges) {
|
||||
const gain = edgesToComm / totalEdges - (sigmaTot[comm] * ki) / (m2 * totalEdges);
|
||||
const lossFromCurrent = kiIn / totalEdges - (sigmaTot[currentComm] * ki) / (m2 * totalEdges);
|
||||
const deltaQ = gain - lossFromCurrent;
|
||||
|
||||
if (deltaQ > bestGain) {
|
||||
bestGain = deltaQ;
|
||||
bestComm = comm;
|
||||
}
|
||||
}
|
||||
|
||||
sigmaTot[currentComm] += ki;
|
||||
|
||||
if (bestComm !== currentComm) {
|
||||
sigmaTot[currentComm] -= ki;
|
||||
sigmaTot[bestComm] += ki;
|
||||
community[i] = bestComm;
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize cluster IDs to 0..N
|
||||
const uniqueComms = [...new Set(community)];
|
||||
const commMap = new Map<number, number>();
|
||||
uniqueComms.forEach((c, i) => commMap.set(c, i));
|
||||
|
||||
const assignments = new Map<string, number>();
|
||||
const clusters = new Map<number, string[]>();
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const clusterId = commMap.get(community[i])!;
|
||||
const nodeId = nodeIds[i];
|
||||
assignments.set(nodeId, clusterId);
|
||||
|
||||
if (!clusters.has(clusterId)) clusters.set(clusterId, []);
|
||||
clusters.get(clusterId)!.push(nodeId);
|
||||
}
|
||||
|
||||
return {
|
||||
assignments,
|
||||
clusterCount: uniqueComms.length,
|
||||
clusters,
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate N visually distinct colors using golden angle distribution */
|
||||
export function clusterColors(count: number): string[] {
|
||||
const colors: string[] = [];
|
||||
const goldenAngle = 137.508;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hue = (i * goldenAngle) % 360;
|
||||
colors.push(`hsl(${hue}, 70%, 65%)`);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
|
@ -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
122
src/lib/frontmatter.ts
Normal 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
65
src/lib/noteCache.ts
Normal 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);
|
||||
|
|
@ -27,5 +27,11 @@ export default defineConfig(async () => ({
|
|||
watch: {
|
||||
ignored: ["**/src-tauri/**", "**/vault/**"],
|
||||
},
|
||||
fs: {
|
||||
allow: [
|
||||
".",
|
||||
path.resolve(__dirname, "../blinksgg/gg-antifragile"),
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue