diff --git a/README.md b/README.md index 104ed0b3..1d1b119a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Notely is built with Electron + React and is designed for project notes, meeting - Preview Mermaid diagrams and rendered Markdown content. - Create and edit structured technical diagrams with **Draw.io integration** directly from markdown previews, supporting drag-and-drop import for `.drawio` and `.drawio.xml` files, image export, and offline drawing. - Visualize the workspace as an interactive note graph. -- Use built-in AI features powered by Vercel AI SDK (Gemini, Groq, OpenAI) with a 3-Brain Architecture (`WorkspaceBrain`, `ReasoningBrain`, `ActionBrain`), autonomous multi-step Planner, semantic domain tools, local-first Embeddings Engine (`BGE-small-en-v1.5` ONNX model), recursive SQLite Knowledge Graph, strict read-only note immutability safeguards, ReAct self-correction engine (`SelfCorrectionEngine`), and an automated diagnostic evaluation harness (`AgentHarness`). +- Use built-in AI features powered by Vercel AI SDK (Gemini, Groq, OpenAI / OpenAI-compatible endpoints) with a 3-Brain Architecture (`WorkspaceBrain`, `ReasoningBrain`, `ActionBrain`), autonomous multi-step Planner, semantic domain tools, local-first ONNX Embeddings Engine (`BGE-small-en-v1.5`), zero-latency Context Compaction (`CompactionEngine`), local GLiNER2 ONNX Knowledge Graph Engine, strict read-only note immutability safeguards, ReAct self-correction engine (`SelfCorrectionEngine`), and an automated diagnostic evaluation harness (`AgentHarness`). - Aggregate tasks across notes with **Open Tasks** and **All Tasks** panels. - Open Tasks focuses on unchecked items. - All Tasks includes open + closed items with filtering and note grouping. @@ -61,7 +61,7 @@ Notely is built with Electron + React and is designed for project notes, meeting - Optional `.notes-app` metadata inclusion (default off) - View note statistics (word count, line count, reading time estimate) in the status bar. - Copy note content as HTML or plain text directly from the editor toolbar. -- Execute JavaScript (`js`/`javascript`) and Python (`py`/`python`) code blocks locally with the interactive β–Ά Run (Play) button in both Markdown Previews and the popup Code Editor modal. Outputs (stdout/stderr) are rendered in an integrated high-contrast dark terminal output pane. +- Execute JavaScript (`js`/`javascript`), Python (`py`/`python`), Bash (`bash`/`sh`), PowerShell (`powershell`/`ps1`), and HTML live-preview code blocks locally with the interactive β–Ά Run (Play) button in both Markdown Previews and the popup Code Editor modal. Outputs (stdout/stderr) are rendered in an integrated high-contrast dark terminal output pane. - Navigate nested folders with breadcrumb links for easy folder traversal. - Navigate active note tabs using **Ctrl+Tab** (next tab) and **Ctrl+Shift+Tab** (previous tab) standard shortcuts. - Copy note link paths relative to the current workspace root from right-click context menus on tabs and dashboard document list items. diff --git a/docs-site/.vitepress/config.mts b/docs-site/.vitepress/config.mts index 91cc8bf9..c8d40d3d 100644 --- a/docs-site/.vitepress/config.mts +++ b/docs-site/.vitepress/config.mts @@ -51,6 +51,7 @@ export default withMermaid( { text: "Home", link: "/" }, { text: "Getting Started", link: "/getting-started/" }, { text: "Editor", link: "/editor/" }, + { text: "Workspace", link: "/workspace/" }, { text: "Git", link: "/git/" }, { text: "AI", link: "/ai/" }, { @@ -92,6 +93,7 @@ export default withMermaid( items: [ { text: "Workspace Overview", link: "/workspace/" }, { text: "Tasks", link: "/workspace/tasks" }, + { text: "Calendar", link: "/workspace/calendar" }, { text: "Media", link: "/workspace/media" }, { text: "Screen Capture", link: "/workspace/screen-capture" }, { text: "Workspace Graph", link: "/workspace/graph" }, diff --git a/docs/ai/setup.md b/docs/ai/setup.md index a023839b..d5ad90e2 100644 --- a/docs/ai/setup.md +++ b/docs/ai/setup.md @@ -1,7 +1,7 @@ --- title: Setting Up AI Providers description: Configure AI settings, API keys, local endpoints, and feature flags. -keywords: AI settings, API key, Ollama, OpenAI, Gemini, Groq, HuggingFace, ONNX, BGE embeddings +keywords: AI settings, API key, OpenAI, Gemini, Groq, HuggingFace, ONNX, BGE embeddings category: AI --- @@ -13,12 +13,11 @@ Configure LLM provider models, API tokens, and local vector index settings insid ## 1. Text Generation Providers -Notely uses the **Vercel AI SDK** and local bindings to connect to multiple LLM APIs: -- **Local (Qwen2.5-0.5B)**: Runs completely on-device and offline. Requires downloading local GGUF weights (~400MB) via the settings dashboard. -- **Google Gemini**: Requires a Gemini API key. Highly recommended for rich tool calling. -- **Groq**: Requires a Groq API key (supports models like `llama-3.3-70b-specdec`). -- **OpenAI Compatible**: Connect to OpenAI or local servers (Ollama, LM Studio) by setting a custom Base URL and Model name. -- **Connection Diagnostics**: Click the **Test** button next to any configured provider to run a diagnostic round-trip test. +Notely connects to cloud and custom LLM providers using the **Vercel AI SDK**: +- **Google Gemini**: Requires a Gemini API key. Default provider (`gemini-2.0-flash`), recommended for rich tool calling. +- **Groq**: Requires a Groq API key (supports models like `llama-3.3-70b-versatile`, `llama-3.1-8b-instant`, `deepseek-r1-distill-llama-70b`). +- **OpenAI / OpenAI-Compatible**: Connect to OpenAI (`gpt-4o`, `gpt-4o-mini`) or custom compatible endpoints by setting an API Key and custom Base URL. +- **Connection Diagnostics**: Click the **Test** button next to any configured provider in **AI Settings** to verify connection status. --- diff --git a/docs/architecture.md b/docs/architecture.md index 9c3bca64..ef45d690 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ The Electron main process (`electron/main.cjs` & `electron/lib/`) coordinates ap * **Vector Embeddings Engine (`EmbeddingDB.js`)**: Stores 384-dimensional `BGE-small` vector chunks in `{workspace}/.notes-app/ai-embeddings.db`. Features physical vector dimension validation (`verifyModelDimensions`) to prevent dimension mismatches. * **Knowledge Graph Subsystem (`GraphService.js`, `GraphDB.js`)**: Maps note relations, tags, mentions, Wikilinks, Images, Local Documents, and External URLs in `{workspace}/.notes-app/ai-graph.db`. Executes relation traversals via SQLite **Recursive Common Table Expressions (CTEs)**. * **Agent & Tool Orchestration**: Integrates with a local embedding runtime and cloud LLMs (Gemini, Groq, OpenAI) using the Vercel AI SDK. -* **Local GGUF Engines**: Supports local text generation and offline graph extraction via `node-llama-cpp`. `LocalModelManager` handles shared runtime loads of the Qwen GGUF model to prevent CPU/RAM overheads. +* **Local ONNX Neural Models**: Vector embeddings (`BGE-small-en-v1.5`) and Knowledge Graph entity/relationship extraction (`gliner2-multi-v1-onnx`) run 100% on-device and offline using `onnxruntime-node`. #### AI Layer Architecture @@ -235,9 +235,11 @@ graph TD end subgraph CacheDir ["πŸ“ .notes-app (Hidden Cache Folder)"] + TDB[("task-db.sqlite
(Task DB & Bi-directional Sync)")] VECDB[("ai-embeddings.db
(384-dim Vector BLOBs)")] GDB[("ai-graph.db
(Entities & CTE Edges)")] LDB[("ai-logs.db
(System & App Logs)")] + STATE["app-state.json / metadata"] end end @@ -261,7 +263,10 @@ graph TD * Video & Audio recordings (`.mp4`, `.webm`, `.mp3`, `.wav`, `.m4a`). * Document attachments (`.pdf`). * Excalidraw drawing files (`.excalidraw`). -* **Hidden Subsystem Folder (`{workspace}/.notes-app/`)**: Internal SQLite caches for AI and system features: +* **Hidden Subsystem Folder (`{workspace}/.notes-app/`)**: Internal SQLite caches for AI, tasks, and system features: + * `task-db.sqlite`: Stores workspace-wide task metadata, priorities, due dates, assignee tags, source note hashes, and line indices for bi-directional checklist synchronization. * `ai-embeddings.db`: Stores chunk text, line offsets, hashes, and 384-dimensional binary vector `BLOB`s. * `ai-graph.db`: Stores extracted Knowledge Graph entity nodes, Wikilinks, media links, and relationship edges. * `ai-logs.db`: Stores multitenant application, git, embedding, graph, and AI log entries. + * `app-state.json`: Caches workspace UI state, last opened note handles, and view preferences. + diff --git a/docs/developer/index.md b/docs/developer/index.md index 0b4039e9..d4fba8cb 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -9,25 +9,90 @@ category: Developer Notely is built with Electron, React, and Vite. -## Architecture +## 1. Core Architecture & Process Model ```mermaid graph TD - A[Electron Main Process] -->|IPC Bridges| B[Electron Preload Script] - B -->|Services| C[React Renderer Frontend] - D[Local Filesystem] <--> A - E[Git Binary] <--> A + A[Electron Main Process main.cjs] -->|ContextBridge| B[Preload Bridge preload.cjs] + B -->|React Hooks & Services| C[React Renderer Process src/] + A -->|UtilityProcess| D[AI Background Worker workerProcess.cjs] + A -->|Node.js child_process| E[Native Git & PTY Terminals] + A <--> F[(SQLite & Markdown Storage)] ``` -- **Main Process (`electron/main.cjs`)**: Handles system calls, window lifecycle, local file input/output, Git operations via `simple-git`, and local network pairing processes. -- **Preload (`electron/preload.cjs`)**: Exposes structured API handles safely to the renderer context using `contextBridge`. -- **Renderer (`src/`)**: Built using React, CodeMirror for the editor canvas, and Lucide for icons. +- **Main Process (`electron/main.cjs`)**: Handles window lifecycle, file I/O, IPC handler registration, menu creation, and system integrations. +- **Background Utility Process (`workerManager.cjs` / `workerProcess.cjs`)**: Spawns an isolated Node.js `UtilityProcess` for asynchronous vector embedding generation and Knowledge Graph indexing. This keeps background indexing CPU spikes off the main thread. +- **Preload Bridge (`electron/preload.cjs`)**: Exposes safe, validated IPC invocation methods to `window.electronAPI`. +- **Renderer Process (`src/`)**: React 18 frontend with Vite, CodeMirror 6 editor canvas, KaTeX rendering, and Lucide icons. --- -## Build Tasks +## 2. IPC Channel Security Guard Pattern + +All `ipcMain.handle` endpoints MUST enforce IPC security guards and payload validation: + +1. **Sender Authentication (`assertTrustedIpcSender`)**: Enforces that IPC invocation events originate strictly from verified internal application renderer windows, rejecting unauthorized external or injected frame messages: + ```javascript + const { assertTrustedIpcSender } = require("./ipcSecurity.cjs"); + ipcMain.handle("myChannel", async (event, rawPayload) => { + assertTrustedIpcSender(BrowserWindow, event, "myChannel"); + // ... + }); + ``` +2. **Payload Schema Validation (`ipcSchemas.cjs`)**: Validate raw payloads against strict schema contracts (`validatePayload`) to ensure type safety before processing file paths or commands. + +--- + +## 3. Development Workflow & Commands + +### Development Server +```bash +npm run dev +``` +Launches Vite HMR server and Electron wrapper simultaneously. + +### Build Production Bundle +```bash +npm run build +``` +Compiles Vite frontend assets and validates CommonJS Electron main scripts. + +### Documentation Site +```bash +npm run docs:dev # Launch VitePress live preview server +npm run docs:build # Build static production docs site +``` + +--- + +## 4. Test Suite Execution + +Notely uses **Vitest** for comprehensive unit, integration, and IPC service testing: + +```bash +# Run all unit and integration tests +npm test + +# Run tests in watch mode +npm run test:watch + +# Run P2P network integration test harness +npm run test:p2p +``` + +### Key Test Directories +- `tests/ai/`: Core AI orchestration, 5-stage `AIFlow`, 4-layer planning, compaction, and facade integrity tests. +- `tests/golden_workspace.test.js`: Workspace creation, note CRUD, task database sync, and file watcher tests. +- `electron/lib/ipc/codeExecutorIpc.test.js`: Code execution runner tests. +- `electron/p2p/p2pLive.test.js`: Peer-to-peer discovery and encrypted handshake tests. + +--- + +## 5. Build & Packaging Scripts + +For generating standalone distribution packages: + +- **Windows Executable Build Script (`build-windows-exe.sh`)**: Compiles and bundles a standalone Windows executable. +- **Release Packaging Script (`release.sh`)**: Automates version stamping, package archive creation, and release checksum generation. +- **Icon Generation (`scripts/generate-icon.cjs`)**: Generates app icons from source image assets (`process.env.NOTELY_ICON_SOURCE`). -- **`npm run dev`**: Starts Vite dev server and runs the Electron wrapper. -- **`npm run build`**: Compiles assets for distribution. -- **`npm run docs:dev`**: Launch VitePress development site. -- **`npm run docs:build`**: Builds the static documentation site. diff --git a/docs/editor/code-blocks.md b/docs/editor/code-blocks.md index 6f7b9a15..89208f90 100644 --- a/docs/editor/code-blocks.md +++ b/docs/editor/code-blocks.md @@ -67,18 +67,24 @@ Click **Save** to write changes back to the note, or **Cancel** to discard. You can run code snippets directly from your notes: 1. Hover over a code block in **Preview** mode. -2. If the block is written in JavaScript (`js`, `javascript`) or Python (`py`, `python`), the **β–Ά Run** button in the hover toolbar will be active. -3. Click **β–Ά Run** to execute the script locally. -4. The output is displayed in a collapsible, high-contrast dark terminal output frame beneath the code block. +2. If the block is written in a supported language, the **β–Ά Run** button in the hover toolbar will be active. +3. Supported execution languages include: + - **JavaScript** (`js`, `javascript`): runs locally via `node`. + - **Python** (`py`, `python`): runs locally via system `python` / `python3`. + - **Bash** (`sh`, `bash`): runs shell scripts locally via `bash` or `sh`. + - **PowerShell** (`ps1`, `powershell`): runs scripts via `powershell` or `pwsh`. + - **HTML** (`html`): renders live HTML DOM output inside an interactive preview drawer. +4. Click **β–Ά Run** to execute the script or preview output. +5. Command outputs (stdout/stderr) are displayed in a collapsible, high-contrast dark terminal output frame beneath the code block. You can also execute code from inside the **Dedicated Code Editor** modal using the **Execute** button in the top toolbar. ::: warning Security Note -Running code execution spawns a local process on your machine using your local `node` or `python`/`python3` installation. Only run code from trusted workspaces and sources. +Running code execution spawns a local process on your machine using your local system environment. Only run code from trusted workspaces and sources. ::: ::: info Execution Limits & Loops -Code execution terminates automatically after 10 seconds. If your code hangs or enters an infinite loop, the runner will kill the subprocess safely and report a timeout error. +Code execution terminates automatically after 10 seconds. If your code hangs or enters an infinite loop, the runner will kill the subprocess safely and report a timeout error. Buffer sizes are capped at 64KB. ::: ## Supported Languages diff --git a/docs/editor/diagrams.md b/docs/editor/diagrams.md index 39fc9289..50d17b17 100644 --- a/docs/editor/diagrams.md +++ b/docs/editor/diagrams.md @@ -128,5 +128,5 @@ Drop any existing `.drawio` or `.drawio.xml` file directly into the Markdown Edi |---|---|---|---| | **Best for** | Fast text-based flows, timelines | Casual sketching, wireframes | Engineering schematics, network charts | | **Editing** | Text syntax | Visual canvas | Visual canvas | -| **Storage** | Plain Markdown text | XML drawing + PNG preview | XML drawing + PNG preview | +| **Storage** | Plain Markdown text | JSON `.excalidraw` + SVG preview | XML `.drawio` + PNG preview | | **Offline** | βœ“ | βœ“ | βœ“ | diff --git a/docs/feature-reference.md b/docs/feature-reference.md index 7b5e22d8..2e78f67f 100644 --- a/docs/feature-reference.md +++ b/docs/feature-reference.md @@ -81,7 +81,7 @@ Notely provides a rich experience for working with code snippets: - **Auto-detection**: Paste a snippet without a language tag and Notely will automatically detect it (e.g., JavaScript, Python, HTML). - **Auto-formatting**: Use the πŸͺ„ Format button in the preview hover toolbar or inside the editor to instantly auto-indent and format your code using Prettier. - **Dedicated Editor**: Click the ✎ Edit button on any code block in Preview mode to open a distraction-free Code Editor popup with syntax highlighting, search, and language selection. -- **Code Execution**: Click the β–Ά Run button in the hover toolbar or inside the popup editor to execute JavaScript (`js`, `javascript`) and Python (`py`, `python`) snippets locally. Output is displayed in an integrated high-contrast dark terminal output drawer (with exit status and a "Clear" button). Execution times out automatically after 10 seconds to prevent hanging. For other languages, the run button is disabled with a helpful tooltip. +- **Code Execution**: Click the β–Ά Run button in the hover toolbar or inside the popup editor to execute JavaScript (`js`), Python (`py`), Bash (`sh`), PowerShell (`ps1`), and HTML live-preview snippets locally. Output is displayed in an integrated high-contrast dark terminal output drawer (with exit status and a "Clear" button). Execution times out automatically after 10 seconds to prevent hanging. For unsupported languages, the run button is disabled with a helpful tooltip. ### Find and replace diff --git a/docs/git/branches.md b/docs/git/branches.md index d0bca4ce..5845d27e 100644 --- a/docs/git/branches.md +++ b/docs/git/branches.md @@ -24,7 +24,7 @@ Configure an upstream remote (like GitHub, GitLab, or a self-hosted Git server) - **Pull**: Fetch and merge changes from the remote repository to update your local workspace. - **Push**: Upload your local commits to the remote repository. -Credentials are saved securely within your system keychain. +Authentication uses Personal Access Tokens (PAT). When performing remote actions with a PAT, Notely temporarily injects the token into the git remote URL for the operation and immediately restores the clean original URL afterwards, keeping plain-text credentials out of persistent repository settings. --- diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index d3a84d47..0f2a2a16 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -7,45 +7,74 @@ category: Reference # Keyboard Shortcuts -Use shortcuts to navigate and edit notes quickly. +Use shortcuts to navigate and edit notes quickly across Notely. -## General Navigation +## Global & Navigation - +| Action | Shortcut | Scope / Notes | +|---|---|---| +| Open Command Palette | `Ctrl/Cmd + K` | Global | +| Open Global Search | `Ctrl/Cmd + Shift + F` | Global | +| Open Keyboard Shortcuts | `Ctrl/Cmd + /` | Global | +| Open Help Center | `F1` | Global | +| Open Markdown Guide | `Ctrl/Cmd + M` | Global | +| Open Workspace | `Ctrl/Cmd + Shift + N` | Workspace | +| Open Workspace Activity | `Ctrl/Cmd + Shift + A` | Workspace | +| Open Workspace Graph | `Ctrl/Cmd + Shift + G` | Workspace | +| Open P2P Status | `Ctrl/Cmd + Shift + P` | Sync | +| Switch to Next Tab | `Ctrl + Tab` | Editor | +| Switch to Previous Tab | `Ctrl + Shift + Tab` | Editor | ## Note Editor - - -## Git & Version Control - - - -## Screen & Media - - +| Action | Shortcut | Scope / Notes | +|---|---|---| +| Create New Note | `Ctrl/Cmd + N` | Notes | +| Save Current Note | `Ctrl/Cmd + S` | Editor | +| Rename Current Note | `F2` | Editor | +| Move Note to Removed | `Ctrl/Cmd + Delete` | Editor | +| Reload Current Note from Disk | `Ctrl/Cmd + Shift + R` | Editor | +| Back to Notes / Close Dialog | `Esc` | Editor / Dialogs | +| Undo | `Ctrl/Cmd + Z` | Editor | +| Redo | `Ctrl/Cmd + Y` or `Ctrl/Cmd + Shift + Z` | Editor | +| Toggle Split Preview | `Ctrl/Cmd + \` | Editor | +| Switch to Edit Mode | `Ctrl/Cmd + 1` | Editor | +| Switch to Split Mode | `Ctrl/Cmd + 2` | Editor | +| Switch to Preview Mode | `Ctrl/Cmd + 3` | Editor | +| Toggle Focus Mode | `Ctrl/Cmd + Alt + F` | Editor | +| Toggle Outline Panel | `Ctrl/Cmd + Alt + L` | Editor | +| Open Reference Note | `Ctrl/Cmd + Shift + K` | Editor | +| Insert Reference Link | `Ctrl/Cmd + Shift + L` | Editor | + +## Search & Find Panel + +| Action | Shortcut | Scope / Notes | +|---|---|---| +| Find in Current Note | `Ctrl/Cmd + F` | Editor | +| Find and Replace in Current Note | `Ctrl/Cmd + H` | Editor (Non-macOS menu accelerator) | +| Next Find Match | `Enter` | Find Panel | +| Previous Find Match | `Shift + Enter` | Find Panel | + +## AI & Features + +| Action | Shortcut | Scope / Notes | +|---|---|---| +| Open AI Palette | `Ctrl/Cmd + Shift + I` | AI (Document screens) | +| Toggle AI Assistant Chat | `Ctrl/Cmd + J` | AI (Document screens) | +| Open AI Settings | `Ctrl/Cmd + Shift + ,` | AI | + +## View, Media & Tools + +| Action | Shortcut | Scope / Notes | +|---|---|---| +| Open Versions / History | `Ctrl/Cmd + Shift + H` | Editor | +| Export PDF | `Ctrl/Cmd + Shift + E` | Editor | +| Open Note in VS Code | `Ctrl/Cmd + Shift + O` | Editor | +| Open Website View | `Ctrl/Cmd + Shift + W` | Editor | +| Capture Screen Area (Windows) | `Ctrl/Cmd + Shift + S` | Media | +| Zoom In / Out / Reset | `Ctrl/Cmd + =` / `-` / `0` | View | +| Open Preview Context Menu | `Shift + F10` / `ContextMenu` | Preview | +| Image Zoom In / Out / Reset | `+` / `-` / `1` or `0` | Media Preview | +| Landing Tile / Table View | `Ctrl/Cmd + 1` / `2` | Landing View | +| Landing Comfortable / Compact Density | `Ctrl/Cmd + 3` / `4` | Landing View | + diff --git a/docs/settings-reference.md b/docs/settings-reference.md index e4702334..238e91fc 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1,149 +1,161 @@ -# Settings Reference - -Use this page when you want to understand what each configurable option does in Notely. - -## 1. Appearance - -### Theme - -Open **Settings -> Theme**. - -- **System**: follows the operating system theme -- **Light**: always uses the light theme -- **Dark**: always uses the dark theme - -Default: **System** - -Use this when you want Notely to stay consistent with your desktop or when you need a fixed light or dark theme. - -### Zoom - -Open **View -> Zoom In**, **Zoom Out**, or **Reset Zoom**. - -- Minimum zoom: 75% -- Default zoom: 80% -- Maximum zoom: 200% - -Use zoom when UI text or panels feel too small or too large on your display. - -## 2. Landing View - -### View Mode - -Open **View -> Tile Notes** or **View -> Table Notes**. - -- **Tile Notes**: visual cards with previews and summary details -- **Table Notes**: denser list-style view for scanning many notes quickly - -### Density - -Open **View -> Comfortable Density** or **Compact Density**. - -- **Comfortable**: more whitespace and larger rows -- **Compact**: denser rows for large workspaces - -Use compact density when you want to see more notes at once. - -## 3. Editor and Writing - -### Typo Check - -Open **Edit -> Enable Typo Check**. - -- When enabled, Notely checks note text for spelling and typo issues. -- It skips code and diagram content so it does not flag technical text unnecessarily. - -Default: **On** - -### Outline - -Open **View -> Show Outline**. - -- Shows a headings-based side panel for the current note -- Hidden automatically while Focus Mode is active - -Shortcut: `Ctrl/Cmd + Alt + L` - -### Focus Mode - -Open **View -> Focus Mode**. - -- Hides the outline and reduces surrounding distractions -- Useful for concentrated writing sessions - -Shortcut: `Ctrl/Cmd + Alt + F` - -## 4. Terminal - -### Show Terminal - -Open **View -> Show Terminal**. - -- Opens or hides the embedded terminal panel -- Useful for project-local commands while staying in Notely - -### Terminal Shell - -Open **View -> Terminal Shell**. - -- **Auto**: lets Notely choose the shell -- **Bash**: prefers Bash when available -- **CMD**: uses Windows Command Prompt - -Use Bash if you prefer a Unix-style command line. Use CMD if you prefer standard Windows commands. - -## 5. Screen Capture - -Open **Settings -> Screen Capture**. - -- **Auto Insert**: inserts the captured image immediately into the note -- **Review Before Insert**: opens the review editor before saving and inserting - -Default: **Auto Insert** - -The toolbar capture button shows the current mode: - -- `A` = Auto Insert -- `R` = Review Before Insert - -## 6. AI Settings - -Open **AI -> AI Settings**. - -### Provider setup - -- **Text provider**: choose the AI service that writes or rewrites text for you -- **HuggingFace token**: turns on meaning-based search and the smarter graph view -- **Test** buttons: check that your saved sign-in details work - -Current UI status: - -- **OpenAI** and **Local LLM** are visible as planned providers and are not currently configurable - -### Feature toggles - -- **Learn user patterns**: lets the app remember how you use AI so it can be more helpful later -- **Generate embeddings**: turns on meaning-based search and related-note features -- **Discover relationships**: helps the graph and AI features find links between related notes -- **Graph confidence threshold**: controls the minimum confidence score (10% to 95%) required for neural-extracted entities and relationships. Adjusting this slider dynamically filters the Knowledge Graph visualization in real time. - -### Advanced generation tuning - -- **Max tokens**: controls how long AI responses are allowed to be -- **Temperature**: controls how safe and predictable, or how varied and creative, the response feels - -Use lower temperature for predictable output. Use higher temperature for brainstorming or variation. - -### Data and privacy controls - -- Shows where AI-related app data is stored on your device -- Lets you clear saved AI working data and learned behavior - -## 7. Workspace Metadata and Git Safety - -If your workspace is also a Git folder, Notely can help keep its own support files out of version control. - -- **Ignore .notes-app: On**: automatically keeps `.notes-app/` ignored in `.gitignore` -- **Ignore .notes-app: Off**: leaves Git ignore management to you - -Use this when your team stores notes in Git but does not want Notely's private support files committed with them. \ No newline at end of file +# Settings Reference + +Use this page when you want to understand what each configurable option does in Notely. + +## 1. Appearance + +### Theme + +Open **Settings -> Theme**. + +- **System**: follows the operating system theme +- **Light**: always uses the light theme +- **Dark**: always uses the dark theme + +Default: **System** + +Use this when you want Notely to stay consistent with your desktop or when you need a fixed light or dark theme. + +### Zoom + +Open **View -> Zoom In**, **Zoom Out**, or **Reset Zoom**. + +- Minimum zoom: 75% +- Default zoom: 80% +- Maximum zoom: 200% + +Use zoom when UI text or panels feel too small or too large on your display. + +## 2. Landing View + +### View Mode + +Open **View -> Tile Notes** or **View -> Table Notes**. + +- **Tile Notes**: visual cards with previews and summary details +- **Table Notes**: denser list-style view for scanning many notes quickly + +### Density + +Open **View -> Comfortable Density** or **Compact Density**. + +- **Comfortable**: more whitespace and larger rows +- **Compact**: denser rows for large workspaces + +Use compact density when you want to see more notes at once. + +## 3. Editor and Writing + +### Typo Check + +Open **Edit -> Enable Typo Check**. + +- When enabled, Notely checks note text for spelling and typo issues. +- It skips code and diagram content so it does not flag technical text unnecessarily. + +Default: **On** + +### Outline + +Open **View -> Show Outline**. + +- Shows a headings-based side panel for the current note +- Hidden automatically while Focus Mode is active + +Shortcut: `Ctrl/Cmd + Alt + L` + +### Focus Mode + +Open **View -> Focus Mode**. + +- Hides the outline and reduces surrounding distractions +- Useful for concentrated writing sessions + +Shortcut: `Ctrl/Cmd + Alt + F` + +## 4. Terminal + +### Show Terminal + +Open **View -> Show Terminal**. + +- Opens or hides the embedded terminal panel +- Useful for project-local commands while staying in Notely + +### Terminal Shell + +Open **View -> Terminal Shell**. + +- **Auto**: lets Notely choose the shell +- **Bash**: prefers Bash when available +- **CMD**: uses Windows Command Prompt + +Use Bash if you prefer a Unix-style command line. Use CMD if you prefer standard Windows commands. + +## 5. Screen Capture + +Open **Settings -> Screen Capture**. + +- **Auto Insert**: inserts the captured image immediately into the note +- **Review Before Insert**: opens the review editor before saving and inserting + +Default: **Auto Insert** + +The toolbar capture button shows the current mode: + +- `A` = Auto Insert +- `R` = Review Before Insert + +## 6. AI Settings + +Open **AI -> AI Settings**. + +### Provider setup + +- **Text provider**: choose the LLM service for writing, summarizing, or refactoring text (Google Gemini, Groq, or OpenAI / OpenAI-compatible). +- **OpenAI Compatible**: connect to OpenAI (`gpt-4o`, `gpt-4o-mini`) or any compatible endpoint by specifying your API key and Base URL. +- **Local ONNX Models**: vector embeddings (`BGE-small-en-v1.5`) and Knowledge Graph extraction (`GLiNER2-Relex`) run completely on-device and offline via `onnxruntime-node`. +- **HuggingFace token**: optional API token for cloud-based embeddings fallback. +- **Test** buttons: verify that your saved credentials and endpoint connections work. + +### Feature toggles + +- **Learn user patterns**: lets the app remember how you use AI so it can be more helpful later +- **Generate embeddings**: turns on meaning-based search and related-note features +- **Discover relationships**: helps the graph and AI features find links between related notes +- **Graph confidence threshold**: controls the minimum confidence score (10% to 95%) required for neural-extracted entities and relationships. Adjusting this slider dynamically filters the Knowledge Graph visualization in real time. + +### Advanced generation tuning + +- **Max tokens**: controls how long AI responses are allowed to be +- **Temperature**: controls how safe and predictable, or how varied and creative, the response feels + +Use lower temperature for predictable output. Use higher temperature for brainstorming or variation. + +### Data and privacy controls + +- Shows where AI-related app data is stored on your device +- Lets you clear saved AI working data and learned behavior + +## 7. Workspace Metadata and Git Safety + +If your workspace is also a Git folder, Notely can help keep its own support files out of version control. + +- **Ignore .notes-app: On**: automatically keeps `.notes-app/` ignored in `.gitignore` +- **Ignore .notes-app: Off**: leaves Git ignore management to you + +Use this when your team stores notes in Git but does not want Notely's private support files committed with them. + +## 8. Environment Variables + +Notely respects system environment variables for advanced runtime configuration: + +| Variable | Values | Purpose | +|---|---|---| +| `NOTELY_TERMINAL_POLICY` | `permissive` \| `strict` | Sets execution policy for embedded terminal. `strict` enforces role and command allowlists. | +| `NOTELY_TERMINAL_REQUIRED_ROLE` | `developer` (default) | Required user role when operating in strict terminal policy. | +| `NOTELY_TERMINAL_ALLOWLIST` | Comma-separated strings | Allowed command executables in strict terminal mode (e.g. `git,npm,node`). | +| `NOTELY_BASH_PATH` | File path | Explicit path to Bash binary on Windows hosts. | +| `GIT_BASH_PATH` | File path | Fallback Git Bash executable path on Windows hosts. | +| `NOTELY_LOG_LEVEL` | `debug` \| `info` \| `warn` \| `error` | System logging threshold for `LogDB`. | +| `NOTES_ROOT` | Workspace path | Environment variable override for default workspace directory path. | \ No newline at end of file diff --git a/docs/sync/index.md b/docs/sync/index.md index a397a92a..561993f5 100644 --- a/docs/sync/index.md +++ b/docs/sync/index.md @@ -7,38 +7,43 @@ category: Sync # P2P Sync -Notely includes peer-to-peer (P2P) sync, allowing you to replicate notes between devices directly without storing data in the cloud. +Notely includes a native peer-to-peer (P2P) synchronization subsystem (`p2pLive.cjs` & `p2pSyncEngine.cjs`), allowing you to replicate notes directly between local devices without cloud servers or third-party storage. -## 1. How it Works +--- + +## 1. Network Discovery & Transport Security -Syncing operates over local networks: -- Devices discover each other using local broadcast protocols. -- Content is encrypted end-to-end. -- Workspace encryption keys are shared during initial pairing. +- **UDP Network Discovery (Port 47653)**: Devices broadcast periodic UDP discovery packets every 4 seconds across the local network subnet to locate active Notely peers. +- **TLS/TCP Transport**: Peer-to-peer payload transfers execute over encrypted TCP connections. +- **AES-256-GCM End-to-End Encryption**: Note deltas and workspace data payloads are encrypted using 256-bit AES-GCM with unique 12-byte initialization vectors (`iv`) and auth tags (`tag`). +- **Workspace Keys & Key TTL**: Pairing generates a 256-bit workspace key stored in global `app.sqlite`. Workspace key expiration (TTL) is configurable from **1 to 365 days** (default 30 days). Expired keys automatically trigger re-authentication. --- -## 2. Pairing Devices +## 2. Pairing Devices & Invite Flow -1. Open **P2P β†’ P2P Status** on both devices. -2. Click **Start Discovery** to scan the local network. -3. On one device, generate an Invite Code. -4. On the second device, click **Pair with Peer** and input the code. -5. Once accepted, add the device to your **Trusted Peers** list. +1. Open **P2P β†’ P2P Status** (`Ctrl/Cmd + Shift + P`) on both devices. +2. Click **Start Discovery** to scan the local subnet for peers on port 47653. +3. On device A, click **Generate Invite** to create a short-lived, 5-minute invite code (`INVITE_TTL_MS = 300000`). +4. On device B, click **Pair with Peer**, enter the invite code, and confirm authorization. +5. Once accepted, add the peer to your **Trusted Peers** whitelist. --- -## 3. Conflict Resolution +## 3. Delta Note Merging & Conflict Resolution -If a note is edited on two devices simultaneously before sync completes: -1. Notely halts automated merges for that note and flags a conflict. -2. Open the **Conflict Resolution Panel** from the P2P status bar indicator. -3. Compare the conflicting versions side-by-side. -4. Select **Keep Local**, **Keep Remote**, or **Merge Manually** to resolve. +- **3-Way Line Delta Engine**: Edits are processed incrementally using line-level note diffs (`buildNoteDelta` / `applyNoteDelta`). Small non-overlapping changes merge automatically without prompting. +- **Automatic Retry Policy**: Unreachable or interrupted sync requests automatically retry up to **5 times** at 2-second intervals (`SYNC_RETRY_INTERVAL_MS = 2000`). +- **Conflict Snapshot Copying**: If simultaneous edits modify the exact same line block on two devices before a delta is applied: + 1. Notely creates a timestamped conflict file: `[note-name].sync-conflict-[timestamp].md`. + 2. Automated sync halts for that specific note to prevent data corruption. + 3. The **P2P Status Panel** alerts you to the conflict. + 4. Open the **Conflict Resolution Panel** to compare versions side-by-side and choose **Keep Local**, **Keep Remote**, or **Merge Manually**. --- -## 4. Key Rotation & Security +## 4. Key Rotation & Security Controls + +- **Rotate Keys**: Generate a new 256-bit workspace key at any time from P2P Settings. Rotated keys are pushed securely to connected trusted peers. +- **Revoke Peers**: Remove any device from your **Trusted Peers** list to block future connection handshakes instantly. -- **Rotate Keys**: Generate new workspace encryption keys from the P2P Settings. -- **Revoke Peers**: Untrust a device at any time to block future sync requests. diff --git a/docs/workspace/calendar.md b/docs/workspace/calendar.md new file mode 100644 index 00000000..e8028104 --- /dev/null +++ b/docs/workspace/calendar.md @@ -0,0 +1,54 @@ +--- +title: Calendar & Scheduling +description: Guide to using the Calendar workspace, task scheduling, due dates, and visual timeline management in Notely. +keywords: calendar, schedule, due dates, task timeline, month view, week view, day view +category: Workspace +--- + +# Calendar & Scheduling + +Notely includes an integrated **Calendar Workspace** that turns scheduled Markdown tasks and due dates into a visual timeline. View project deadlines, schedule tasks across days or hours, and track upcoming workloads visually. + +--- + +## 1. Task Scheduling & Due Dates + +Tasks in Notely support both **Due Dates** and **Scheduled Start/End Times**: + +- **Due Date**: Target completion deadline for a task (e.g. `2026-08-15`). +- **Scheduled Start / End**: Specific time window when work is planned (e.g. `2026-08-15 09:00 - 11:00`). +- **All-Day Tasks**: Tasks scheduled for an entire day without specific hour constraints. + +--- + +## 2. Calendar Views + +Access the Calendar from the left navigation sidebar or Command Palette (`Ctrl + Shift + C`). The Calendar supports three main view modes: + +### Month View +Provides a high-level overview of the entire month. Scheduled tasks and due date markers appear inside their respective date cells. Click any date cell to quickly inspect or add tasks for that day. + +### Week View +A 7-day grid showing time slots for hourly scheduled tasks alongside an **All-Day Header** for day-wide deadlines. Perfect for weekly sprint planning and time-blocking. + +### Day View +A detailed single-day timeline for granular schedule management and hourly focus blocks. + +--- + +## 3. Interactive Drag & Drop Scheduling + +- **Reschedule Tasks**: Drag tasks across calendar date cells to change their due dates or scheduled days automatically. +- **Time Slot Placement**: In Week or Day views, drag tasks onto specific hour slots to assign start and end times. +- **Quick Status Toggle**: Click task markers directly within calendar cells to mark them as completed without leaving the calendar view. + +--- + +## 4. Date Filtering & Overdue Tracking + +The Calendar integration automatically categorizes workspace tasks into smart time filters: + +- **Due Today**: Tasks whose due date matches the current calendar day. +- **Overdue**: Open tasks whose due date has passed. Overdue items are highlighted in red for quick visibility. +- **Upcoming**: Scheduled tasks occurring over the next 7 to 30 days. +- **Unscheduled Queue**: A side panel listing open tasks that haven't been assigned a due date yet, allowing easy drag-and-drop onto the calendar. diff --git a/docs/workspace/index.md b/docs/workspace/index.md index 75a9f552..5778166d 100644 --- a/docs/workspace/index.md +++ b/docs/workspace/index.md @@ -36,7 +36,15 @@ Customize how you browse your workspace contents: - **Comfortable Density**: Added margins and breathing room. - **Compact Density**: High-density display for reviewing many documents at once. -Switch views from the **View** menu or the Command Palette. +--- + +## 4. Workspace Tools & Embedded Terminal + +Notely includes integrated workspace utilities for project productivity: +- **[Tasks & Checklists](/workspace/tasks)**: Workspace-wide task tracking with bi-directional markdown sync. +- **[Calendar & Scheduling](/workspace/calendar)**: Visual timeline, due dates, and hourly schedule grid. +- **[Knowledge Graph](/workspace/graph)**: Interactive entity and relationship visualization. +- **[Embedded Terminal](/workspace/terminal)**: Project-local command execution with PTY process control and security policies. --- diff --git a/docs/workspace/tasks.md b/docs/workspace/tasks.md index 769cdd16..66b8a844 100644 --- a/docs/workspace/tasks.md +++ b/docs/workspace/tasks.md @@ -1,34 +1,84 @@ --- title: Tasks Management -description: Manage tasks across notes, view open and completed tasks, and use the task panels. -keywords: tasks, checklists, todos, task dashboard, task panel +description: Comprehensive documentation for task tracking, interactive preview task toggling, task detail modal, bi-directional markdown synchronization, and workspace-wide task management in Notely. +keywords: tasks, checklists, todos, task workspace, task detail modal, bi-directional sync, deduplication category: Workspace --- -# Tasks +# Tasks & Checklists -Notely tracks standard Markdown checklist checkboxes (`- [ ]`) across all notes, aggregating them into workspace-wide dashboards. +Notely turns standard Markdown checklist checkboxes into a workspace-wide task management system. Tasks written inside any note automatically sync in real-time with a SQLite task database, providing bi-directional status updates, detail management, and workspace-wide task views. -## 1. Syntax +--- + +## 1. Creating Tasks in Notes + +Create tasks inside any note using standard Markdown checklist syntax: -Create tasks in any note using Markdown syntax: ```markdown -- [ ] An open task -- [x] A completed task +- [ ] Open task +- [x] Completed task +1. [ ] Numbered list task +- [ ] High priority task #work @alex +``` + +### Supported Formats +- **Bullet Lists**: `- [ ]`, `* [ ]`, `+ [ ]` +- **Numbered Lists**: `1. [ ]`, `2. [ ]` +- **Standalone Paragraph Tasks**: `[ ] Standalone task` + +--- + +## 2. Interactive Preview Toggling & Task Modal + +When viewing notes in **Preview Mode** or **Split View**: + +- **Click to Toggle**: Clicking the `[ ]` checkbox directly toggles its status between `Open` (`[ ]`) and `Completed` (`[x]`). +- **Interactive Task Modal**: Clicking the task text opens the **Task Detail Modal** where you can manage extended task attributes: + - **Status**: Toggle between `Open`, `In Progress`, and `Completed`. + - **Priority**: Set priority levels (`Low`, `Medium`, `High`, `Urgent`). + - **Due Date & Scheduling**: Assign due dates, start/end times, and all-day flags. + - **Assignees & Tags**: Tag team members or add custom categories. + - **Comments**: Add discussion threads to individual tasks. + - **Note Source Link**: Jump directly to the exact line number in the source note. + +--- + +## 3. Bi-Directional Markdown Synchronization + +Task statuses update bi-directionally between your Markdown files and the Task Workspace: + +``` ++------------------+ Real-Time IPC +-------------------+ +| Markdown Note | <=========================> | SQLite Task DB & | +| (- [x] Task 1) | Pre-Save Buffer Sync | Task Workspace UI | ++------------------+ +-------------------+ ``` -You can also use `*` or `+` as list indicators. + +- **Markdown β†’ Task DB**: Edits inside the text editor automatically sync to the task database whenever the note is saved. +- **Task DB β†’ Markdown**: Updating task status from the Task Workspace or Task Detail Modal writes `- [x]` or `- [ ]` directly back into the `.md` file. +- **Buffer Safety**: Active unsaved editor changes are committed to disk before task updates execute, preventing data loss, file watcher hash mismatches, or disk conflict warnings. --- -## 2. Aggregated Task Panels +## 4. Smart Alignment & Deduplication Engine -Access your tasks workspace-wide using the Command Palette (`Ctrl + K`): +Notely uses a **4-pass alignment algorithm** to maintain task identity across note refactoring and line shifts: -- **Open Tasks Panel**: Displays a searchable list of all pending (`- [ ]`) tasks, grouped by the note they belong to. Click "Open note" to navigate directly to the task location. -- **All Tasks Panel**: Displays both open and completed tasks. Helpful for auditing, generating changelogs, or reviewing project velocity. +1. **Pass 1: Exact Hash Match**: Matches `SHA1(filePath + title + occurrenceIndex)`. +2. **Pass 2: Line Number Match**: Preserves task identity when task titles are edited on the same line index. +3. **Pass 3: Fuzzy Similarity Match**: Uses Jaccard word similarity (>0.35 threshold) to track tasks when lines move and title text is tweaked simultaneously. +4. **Pass 4: Orphan Purging**: Auto-removes database records when tasks are permanently deleted from a Markdown file. --- -## 3. Note-Level Task Summary +## 5. Note-Level Task Bar & Workspace Dashboard + +### Note-Level Task Summary +When editing any note, the top header displays a live task completion progress indicator (e.g. `2/5 tasks`). Clicking this indicator opens the **Task Summary Popover**, listing all open and completed tasks for that note with direct line-jump buttons. -When editing a note, the editor top bar displays a compact task completion indicator. Hovering over it shows a summary of open and completed items within the active note. Clicking it launches the task summary popup. +### Workspace Task Page +Access the full Task Workspace via the left sidebar or Command Palette (`Ctrl + Shift + T`): +- **Filter by Note**: Focus on tasks from the active note or view tasks across all project notes. +- **Status Tabs**: Easily switch between `All`, `Open`, `In Progress`, and `Completed`. +- **Search & Sort**: Filter tasks by title, priority, due date, or assignee. diff --git a/docs/workspace/terminal.md b/docs/workspace/terminal.md new file mode 100644 index 00000000..3a6c9e70 --- /dev/null +++ b/docs/workspace/terminal.md @@ -0,0 +1,67 @@ +--- +title: Embedded Terminal +description: Work with terminal commands, local scripts, and build tools directly inside Notely. +keywords: terminal, shell, bash, cmd, powershell, pty, node-pty, terminal policy, allowlist +category: Workspace +--- + +# Embedded Terminal + +Notely features an integrated, full-featured **Embedded Terminal** powered by `node-pty`. Run project-local commands, build scripts, git commands, and shell utilities without leaving your workspace. + +--- + +## 1. Opening & Toggling the Terminal + +- **View Menu**: Open **View β†’ Show Terminal**. +- **Command Palette**: Press `Ctrl/Cmd + K` and select **Show Terminal**. +- **Bottom Panel**: Toggle the terminal drawer at the bottom of your workspace layout. + +--- + +## 2. Shell Selection & Configuration + +Select your preferred shell from **View β†’ Terminal Shell**: + +- **Auto**: Automatically selects the default shell based on your host operating system. +- **Bash**: Prefers Bash when installed (e.g. Git Bash on Windows or native Bash on macOS/Linux). +- **CMD**: Uses standard Windows Command Prompt (`cmd.exe`). +- **PowerShell**: Uses PowerShell (`powershell.exe` or `pwsh`). + +### Custom Shell Path Overrides (Environment Variables) + +On Windows systems, you can override default shell binary paths using environment variables: + +- `NOTELY_BASH_PATH`: Absolute path to a specific Bash executable. +- `GIT_BASH_PATH`: Fallback path to Git Bash executable. + +--- + +## 3. Working Directory & Lifecycle + +- **Workspace Alignment**: The terminal automatically sets its initial working directory (CWD) to your active project workspace root (`{workspace}/`). +- **Session Lifecycle**: Terminal PTY sessions persist as long as the panel is open. Closing the terminal drawer terminates the PTY subprocess cleanly to free system resources. +- **Resize Behavior**: The terminal automatically adjusts its columns and rows dynamically as you resize the panel drawer. + +--- + +## 4. Security Policies & Strict Allowlist Mode + +Notely includes enterprise security controls to restrict terminal command execution when operating in sensitive workspace environments: + +### Policy Modes (`NOTELY_TERMINAL_POLICY`) + +- **Permissive Mode (`permissive`, default)**: Standard interactive shell access allowing any user command. +- **Strict Mode (`strict`)**: Restricts command execution to a pre-approved list of command binaries and enforces user role policies. + +### Environment Variable Security Flags + +| Variable | Description | +|---|---| +| `NOTELY_TERMINAL_POLICY` | Set to `strict` to enforce command allowlisting. | +| `NOTELY_TERMINAL_REQUIRED_ROLE` | Required user role (defaults to `developer`). | +| `NOTELY_TERMINAL_ALLOWLIST` | Comma-separated list of allowed command executables (e.g. `git,npm,node,dir,ls`). | + +::: warning Strict Policy Enforcement +When `NOTELY_TERMINAL_POLICY=strict` is active, any command execution attempt outside the `NOTELY_TERMINAL_ALLOWLIST` is blocked by IPC security guards before spawning. +::: diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index 2167b987..d09a9a78 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -134,7 +134,7 @@ function buildAppMenuTemplate(win, context = {}) { click: () => sendMenuAction(win, "reload-document") }, { - label: "Move Note to Removed", + label: "Remove Note", accelerator: "CmdOrCtrl+Delete", click: () => sendMenuAction(win, "remove-document") }, @@ -548,6 +548,17 @@ function buildAppMenuTemplate(win, context = {}) { { label: "Workspace", submenu: [ + { + label: "Tasks", + accelerator: "CmdOrCtrl+Shift+T", + click: () => sendMenuAction(win, "open-tasks-workspace") + }, + { + label: "Calendar", + accelerator: "CmdOrCtrl+Shift+L", + click: () => sendMenuAction(win, "open-calendar") + }, + { type: "separator" }, { label: "Workspace Information", click: () => sendMenuAction(win, "open-workspace-info") diff --git a/electron/lib/tasks/TaskDatabase.cjs b/electron/lib/tasks/TaskDatabase.cjs new file mode 100644 index 00000000..1a880b62 --- /dev/null +++ b/electron/lib/tasks/TaskDatabase.cjs @@ -0,0 +1,732 @@ +'use strict'; + +const path = require('node:path'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); + +const TASK_DB_DIR = '.notes-app'; +const TASK_DB_FILE = 'task-db.sqlite'; +const TASK_JSON_FILE = 'tasks.json'; + +function hashSource(filePath, lineText, occurrenceIndex = 1) { + const cleanText = String(lineText || "") + .replace(/^[ \t]*[-*+]?[ \t]*\[[ xX]\][ \t]*/, "") + .trim() + .toLowerCase(); + const suffix = occurrenceIndex > 1 ? `::${occurrenceIndex}` : ""; + return crypto.createHash('sha1').update(`${filePath}\x00${cleanText}${suffix}`).digest('hex'); +} + +function calculateSimilarity(str1, str2) { + const s1 = String(str1 || '').toLowerCase().trim(); + const s2 = String(str2 || '').toLowerCase().trim(); + if (s1 === s2) return 1.0; + if (!s1 || !s2) return 0; + const words1 = new Set(s1.split(/\s+/)); + const words2 = new Set(s2.split(/\s+/)); + let intersection = 0; + for (const w of words1) { + if (words2.has(w)) intersection++; + } + const union = new Set([...words1, ...words2]).size; + return union ? intersection / union : 0; +} + +function randomId() { + return crypto.randomBytes(10).toString('hex'); +} + +function nowISO() { + return new Date().toISOString(); +} + +const SCHEMA = ` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + priority INTEGER NOT NULL DEFAULT 0, + source_path TEXT, + source_line INTEGER, + source_hash TEXT UNIQUE, + user_managed INTEGER NOT NULL DEFAULT 0, + due_date TEXT, + scheduled_start TEXT, + scheduled_end TEXT, + is_all_day INTEGER NOT NULL DEFAULT 1, + reminder TEXT, + person_tags TEXT NOT NULL DEFAULT '[]', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT + ); + + CREATE TABLE IF NOT EXISTS task_comments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + author TEXT NOT NULL DEFAULT 'me', + body TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS persons ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); + CREATE INDEX IF NOT EXISTS idx_tasks_source_path ON tasks(source_path); + CREATE INDEX IF NOT EXISTS idx_task_comments_task_id ON task_comments(task_id); +`; + +class TaskDatabase { + constructor(workspaceRoot) { + this.workspaceRoot = workspaceRoot; + this.db = null; + this.jsonState = null; + this.jsonPath = null; + this._open(); + } + + _open() { + if (!this.workspaceRoot) return; + const dir = path.join(this.workspaceRoot, TASK_DB_DIR); + if (!fs.existsSync(dir)) { + try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ } + } + + // Try SQLite first + try { + const sqliteModule = require('node:sqlite'); + const DatabaseSync = sqliteModule?.DatabaseSync; + if (DatabaseSync) { + const dbPath = path.join(dir, TASK_DB_FILE); + this.db = new DatabaseSync(dbPath); + this.db.exec(SCHEMA); + return; + } + } catch (err) { + console.warn('[TaskDatabase] SQLite unavailable, using JSON fallback:', err?.message || err); + this.db = null; + } + + // JSON fallback + this.jsonPath = path.join(dir, TASK_JSON_FILE); + try { + if (fs.existsSync(this.jsonPath)) { + this.jsonState = JSON.parse(fs.readFileSync(this.jsonPath, 'utf8')); + } + } catch { + /* ignore */ + } + if (!this.jsonState) { + this.jsonState = { tasks: [], comments: [], persons: [] }; + } + } + + _saveJson() { + if (!this.jsonPath || !this.jsonState) return; + try { + fs.writeFileSync(this.jsonPath, JSON.stringify(this.jsonState, null, 2), 'utf8'); + } catch { /* ignore */ } + } + + close() { + try { this.db?.close(); } catch { /* ignore */ } + this.db = null; + } + + // ── Tasks ───────────────────────────────────────────────────────────────── + + syncFromNote(filePath, parsedTasks) { + const now = nowISO(); + let inserted = 0; + let updated = 0; + + if (this.db) { + const existingDbTasks = this.db.prepare( + 'SELECT * FROM tasks WHERE source_path = ? AND user_managed = 0' + ).all(filePath).map(r => this._deserialize(r)); + + const matchedTaskIds = new Set(); + const matchedParsedIndices = new Set(); + + // Pass 1: Exact hash match (title + filePath) + for (let i = 0; i < parsedTasks.length; i++) { + const t = parsedTasks[i]; + const hash = hashSource(filePath, t.title); + const match = existingDbTasks.find(dbT => !matchedTaskIds.has(dbT.id) && dbT.source_hash === hash); + + if (match) { + matchedTaskIds.add(match.id); + matchedParsedIndices.add(i); + if (match.status !== t.status || match.source_line !== t.line) { + const completedAt = t.status === 'done' ? now : (t.status === 'open' ? null : match.completed_at); + this.db.prepare( + 'UPDATE tasks SET status = ?, source_line = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(t.status, t.line, now, completedAt, match.id); + updated++; + } + } + } + + // Pass 2: Line index match for title edits on same line + for (let i = 0; i < parsedTasks.length; i++) { + if (matchedParsedIndices.has(i)) continue; + const t = parsedTasks[i]; + const match = existingDbTasks.find(dbT => !matchedTaskIds.has(dbT.id) && dbT.source_line === t.line); + + if (match) { + matchedTaskIds.add(match.id); + matchedParsedIndices.add(i); + const newHash = hashSource(filePath, t.title); + const completedAt = t.status === 'done' ? now : null; + this.db.prepare( + 'UPDATE tasks SET title = ?, status = ?, source_hash = ?, source_line = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(t.title, t.status, newHash, t.line, now, completedAt, match.id); + updated++; + } + } + + // Pass 3: Fuzzy title / positional alignment for remaining unmatched tasks + const unmatchedDb = existingDbTasks.filter(dbT => !matchedTaskIds.has(dbT.id)); + const unmatchedParsed = parsedTasks.map((t, idx) => ({ ...t, idx })).filter(t => !matchedParsedIndices.has(t.idx)); + + for (const p of unmatchedParsed) { + let bestMatch = null; + let bestScore = 0; + + for (const dbT of unmatchedDb) { + if (matchedTaskIds.has(dbT.id)) continue; + const score = calculateSimilarity(dbT.title, p.title); + if (score > 0.35 && score > bestScore) { + bestScore = score; + bestMatch = dbT; + } + } + + if (!bestMatch && unmatchedDb.length === 1 && unmatchedParsed.length === 1) { + bestMatch = unmatchedDb[0]; + } + + if (bestMatch) { + matchedTaskIds.add(bestMatch.id); + matchedParsedIndices.add(p.idx); + const newHash = hashSource(filePath, p.title); + const completedAt = p.status === 'done' ? now : null; + this.db.prepare( + 'UPDATE tasks SET title = ?, status = ?, source_hash = ?, source_line = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(p.title, p.status, newHash, p.line, now, completedAt, bestMatch.id); + updated++; + } else { + // Insert new task safely checking if source_hash already exists + const hash = hashSource(filePath, p.title); + const existingHashMatch = this.db.prepare('SELECT id FROM tasks WHERE source_hash = ?').get(hash); + + if (existingHashMatch) { + const completedAt = p.status === 'done' ? now : null; + this.db.prepare( + 'UPDATE tasks SET status = ?, source_line = ?, updated_at = ?, completed_at = ? WHERE id = ?' + ).run(p.status, p.line, now, completedAt, existingHashMatch.id); + updated++; + } else { + this.db.prepare(` + INSERT INTO tasks (id, title, status, source_path, source_line, source_hash, + user_managed, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) + `).run(randomId(), p.title, p.status, filePath, p.line, hash, now, now); + inserted++; + } + } + } + + // Pass 4: Purge orphaned DB tasks for this file + for (const dbT of existingDbTasks) { + if (!matchedTaskIds.has(dbT.id)) { + this.db.prepare('DELETE FROM tasks WHERE id = ?').run(dbT.id); + } + } + + return { inserted, updated }; + } + + if (this.jsonState) { + const activeHashSet = new Set(); + for (const t of parsedTasks) { + const hash = hashSource(filePath, t.title); + activeHashSet.add(hash); + const existing = this.jsonState.tasks.find(x => x.source_hash === hash); + + if (!existing) { + this.jsonState.tasks.push({ + id: randomId(), + title: t.title, + description: '', + status: t.status, + priority: 0, + source_path: filePath, + source_line: t.line, + source_hash: hash, + user_managed: 0, + due_date: null, + scheduled_start: null, + scheduled_end: null, + is_all_day: 1, + reminder: null, + person_tags: [], + metadata: {}, + created_at: now, + updated_at: now, + completed_at: t.status === 'done' ? now : null, + }); + inserted++; + } else if (!existing.user_managed && existing.status !== t.status) { + existing.status = t.status; + existing.source_line = t.line; + existing.updated_at = now; + existing.completed_at = t.status === 'done' ? now : null; + updated++; + } else if (existing.source_line !== t.line) { + existing.source_line = t.line; + } + } + + // Purge orphaned tasks for this file + this.jsonState.tasks = this.jsonState.tasks.filter( + t => t.source_path !== filePath || t.user_managed === 1 || activeHashSet.has(t.source_hash) + ); + + this._saveJson(); + } + + return { inserted, updated }; + } + + listTasks({ status, priority, dueBefore, noteFilter, search, limit = 200, offset = 0 } = {}) { + if (this.db) { + let sql = 'SELECT * FROM tasks WHERE 1=1'; + const params = []; + + if (status && status !== 'all') { + if (status === 'overdue') { + sql += " AND status = 'open' AND due_date IS NOT NULL AND due_date < date('now')"; + } else if (status === 'today') { + sql += " AND status = 'open' AND due_date = date('now')"; + } else if (status === 'upcoming') { + sql += " AND status = 'open' AND (due_date IS NULL OR due_date >= date('now'))"; + } else { + sql += ' AND status = ?'; + params.push(status); + } + } + + if (priority != null && priority >= 0) { + sql += ' AND priority = ?'; + params.push(priority); + } + + if (dueBefore) { + sql += ' AND due_date IS NOT NULL AND due_date <= ?'; + params.push(dueBefore); + } + + if (noteFilter) { + sql += ' AND source_path = ?'; + params.push(noteFilter); + } + + if (search) { + sql += ' AND (title LIKE ? OR description LIKE ?)'; + const needle = `%${search}%`; + params.push(needle, needle); + } + + sql += " ORDER BY CASE status WHEN 'open' THEN 0 ELSE 1 END, due_date ASC NULLS LAST, priority DESC, updated_at DESC"; + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return this.db.prepare(sql).all(...params).map(r => this._deserialize(r)); + } + + if (this.jsonState) { + const todayStr = new Date().toISOString().slice(0, 10); + let list = [...this.jsonState.tasks]; + + if (status && status !== 'all') { + if (status === 'overdue') { + list = list.filter(t => t.status === 'open' && t.due_date && t.due_date < todayStr); + } else if (status === 'today') { + list = list.filter(t => t.status === 'open' && t.due_date === todayStr); + } else if (status === 'upcoming') { + list = list.filter(t => t.status === 'open' && (t.due_date == null || t.due_date >= todayStr)); + } else { + list = list.filter(t => t.status === status); + } + } + + if (priority != null && priority >= 0) { + list = list.filter(t => (t.priority ?? 0) === priority); + } + + if (dueBefore) { + list = list.filter(t => t.due_date && t.due_date <= dueBefore); + } + + if (noteFilter) { + list = list.filter(t => t.source_path === noteFilter); + } + + if (search) { + const needle = search.toLowerCase(); + list = list.filter(t => (t.title || '').toLowerCase().includes(needle) || (t.description || '').toLowerCase().includes(needle)); + } + + list.sort((a, b) => { + if (a.status !== b.status) return a.status === 'open' ? -1 : 1; + if (a.due_date !== b.due_date) { + if (!a.due_date) return 1; + if (!b.due_date) return -1; + return a.due_date.localeCompare(b.due_date); + } + return (b.priority ?? 0) - (a.priority ?? 0); + }); + + return list.slice(offset, offset + limit); + } + + return []; + } + + getTask(id) { + if (this.db) { + const row = this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + return row ? this._deserialize(row) : null; + } + if (this.jsonState) { + return this.jsonState.tasks.find(t => t.id === id) ?? null; + } + return null; + } + + createTask({ title, description = '', status = 'open', priority = 0, sourcePath, sourceLine, + dueDate, scheduledStart, scheduledEnd, isAllDay = 1, + reminder, personTags = [], metadata = {} }) { + const now = nowISO(); + const isNoteTask = Boolean(sourcePath && sourcePath !== 'none'); + const sourceHash = isNoteTask ? hashSource(sourcePath, title) : null; + + if (this.db) { + if (sourceHash) { + const existing = this.db.prepare('SELECT id FROM tasks WHERE source_hash = ?').get(sourceHash); + if (existing) { + this.updateTask(existing.id, { title, description, status, priority, dueDate, scheduledStart, scheduledEnd, isAllDay, reminder, personTags }); + return this.getTask(existing.id); + } + } + + const id = randomId(); + this.db.prepare(` + INSERT INTO tasks (id, title, description, status, priority, source_path, source_line, source_hash, + user_managed, due_date, scheduled_start, scheduled_end, is_all_day, reminder, + person_tags, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, title, description, status, priority, isNoteTask ? sourcePath : null, sourceLine ?? null, sourceHash, + isNoteTask ? 0 : 1, dueDate ?? null, scheduledStart ?? null, scheduledEnd ?? null, isAllDay ? 1 : 0, + reminder ?? null, JSON.stringify(personTags), JSON.stringify(metadata), now, now); + return this.getTask(id); + } + + if (this.jsonState) { + if (sourceHash) { + const existing = this.jsonState.tasks.find(t => t.source_hash === sourceHash); + if (existing) { + this.updateTask(existing.id, { title, description, status, priority, dueDate, scheduledStart, scheduledEnd, isAllDay, reminder, personTags }); + return this.getTask(existing.id); + } + } + + const id = randomId(); + const task = { + id, title, description, status, priority, + source_path: isNoteTask ? sourcePath : null, + source_line: sourceLine ?? null, + source_hash: sourceHash, + user_managed: isNoteTask ? 0 : 1, + due_date: dueDate ?? null, + scheduled_start: scheduledStart ?? null, + scheduled_end: scheduledEnd ?? null, + is_all_day: isAllDay ? 1 : 0, + reminder: reminder ?? null, + person_tags: personTags, + metadata, + created_at: now, + updated_at: now, + completed_at: status === 'done' ? now : null, + }; + this.jsonState.tasks.push(task); + this._saveJson(); + return task; + } + + return null; + } + + updateTask(id, fields) { + const now = nowISO(); + + if (this.db) { + const allowed = ['title', 'description', 'status', 'priority', 'due_date', 'scheduled_start', + 'scheduled_end', 'is_all_day', 'reminder', 'person_tags', 'metadata', + 'user_managed', 'completed_at']; + const sets = []; + const params = []; + + for (const [key, val] of Object.entries(fields)) { + const col = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + if (!allowed.includes(col)) continue; + const stored = (col === 'person_tags' || col === 'metadata') ? JSON.stringify(val) : val; + sets.push(`${col} = ?`); + params.push(stored); + } + + if (!sets.length) return this.getTask(id); + + if (fields.status === 'done' && !fields.completedAt) { + sets.push('completed_at = ?'); + params.push(now); + } else if (fields.status === 'open') { + sets.push('completed_at = ?'); + params.push(null); + } + + sets.push('updated_at = ?', 'user_managed = 1'); + params.push(now); + + this.db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...params, id); + return this.getTask(id); + } + + if (this.jsonState) { + const task = this.jsonState.tasks.find(t => t.id === id); + if (!task) return null; + + for (const [key, val] of Object.entries(fields)) { + const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + task[snakeKey] = val; + } + task.user_managed = 1; + task.updated_at = now; + + if (fields.status === 'done' && !fields.completedAt) { + task.completed_at = now; + } else if (fields.status === 'open') { + task.completed_at = null; + } + + this._saveJson(); + return task; + } + + return null; + } + + deleteTask(id) { + if (this.db) { + this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id); + return true; + } + if (this.jsonState) { + const idx = this.jsonState.tasks.findIndex(t => t.id === id); + if (idx !== -1) { + this.jsonState.tasks.splice(idx, 1); + this._saveJson(); + return true; + } + } + return false; + } + + getOverdueTasks() { + return this.listTasks({ status: 'overdue', limit: 1000 }); + } + + // ── Comments ────────────────────────────────────────────────────────────── + + addComment(taskId, { body, author = 'me' }) { + const id = randomId(); + const now = nowISO(); + + if (this.db) { + this.db.prepare( + 'INSERT INTO task_comments (id, task_id, author, body, created_at) VALUES (?, ?, ?, ?, ?)' + ).run(id, taskId, author, body, now); + return { id, taskId, author, body, createdAt: now }; + } + + if (this.jsonState) { + const comment = { id, taskId, author, body, createdAt: now }; + this.jsonState.comments.push(comment); + this._saveJson(); + return comment; + } + + return null; + } + + getComments(taskId) { + if (this.db) { + return this.db.prepare( + 'SELECT id, task_id AS taskId, author, body, created_at AS createdAt FROM task_comments WHERE task_id = ? ORDER BY created_at ASC' + ).all(taskId); + } + if (this.jsonState) { + return this.jsonState.comments + .filter(c => c.taskId === taskId) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + return []; + } + + // ── Persons ─────────────────────────────────────────────────────────────── + + listPersons() { + if (this.db) { + return this.db.prepare('SELECT * FROM persons ORDER BY name ASC').all(); + } + if (this.jsonState) { + return [...this.jsonState.persons].sort((a, b) => a.name.localeCompare(b.name)); + } + return []; + } + + upsertPerson({ id, name, email = '', avatar = '' }) { + const now = nowISO(); + const pid = id || randomId(); + + if (this.db) { + this.db.prepare(` + INSERT INTO persons (id, name, email, avatar, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, email = excluded.email, avatar = excluded.avatar + `).run(pid, name, email, avatar, now); + return this.db.prepare('SELECT * FROM persons WHERE id = ?').get(pid); + } + + if (this.jsonState) { + let p = this.jsonState.persons.find(x => x.id === pid); + if (p) { + p.name = name; + p.email = email; + p.avatar = avatar; + } else { + p = { id: pid, name, email, avatar, created_at: now }; + this.jsonState.persons.push(p); + } + this._saveJson(); + return p; + } + + return null; + } + + deletePerson(id) { + if (this.db) { + this.db.prepare('DELETE FROM persons WHERE id = ?').run(id); + return true; + } + if (this.jsonState) { + const idx = this.jsonState.persons.findIndex(p => p.id === id); + if (idx !== -1) { + this.jsonState.persons.splice(idx, 1); + this._saveJson(); + return true; + } + } + return false; + } + + // ── Calendar Events ─────────────────────────────────────────────────────── + + getCalendarTaskEvents(startDate, endDate) { + if (this.db) { + const rows = this.db.prepare(` + SELECT id, title, status, due_date, scheduled_start, scheduled_end, + is_all_day, priority, source_path, completed_at + FROM tasks + WHERE ( + (due_date IS NOT NULL AND due_date BETWEEN ? AND ?) + OR (scheduled_start IS NOT NULL AND date(scheduled_start) BETWEEN ? AND ?) + OR (completed_at IS NOT NULL AND date(completed_at) BETWEEN ? AND ?) + ) + `).all(startDate, endDate, startDate, endDate, startDate, endDate); + + return rows.map(r => ({ + ...this._deserialize(r), + _eventType: r.completed_at ? 'task-completed' + : r.scheduled_start ? 'task-scheduled' + : 'task-due', + })); + } + + if (this.jsonState) { + return this.jsonState.tasks + .filter(t => { + const due = t.due_date && t.due_date >= startDate && t.due_date <= endDate; + const sched = t.scheduled_start && t.scheduled_start.slice(0, 10) >= startDate && t.scheduled_start.slice(0, 10) <= endDate; + const comp = t.completed_at && t.completed_at.slice(0, 10) >= startDate && t.completed_at.slice(0, 10) <= endDate; + return due || sched || comp; + }) + .map(t => ({ + ...t, + _eventType: t.completed_at ? 'task-completed' + : t.scheduled_start ? 'task-scheduled' + : 'task-due', + })); + } + + return []; + } + + _deserialize = (row) => { + return { + ...row, + personTags: this._parseJson(row.person_tags, []), + metadata: this._parseJson(row.metadata, {}), + isAllDay: Boolean(row.is_all_day), + userManaged: Boolean(row.user_managed), + }; + }; + + _parseJson = (val, fallback) => { + try { return JSON.parse(val); } catch { return fallback; } + }; +} + +const _instances = new Map(); + +function getTaskDatabase(workspaceRoot) { + if (!workspaceRoot) return null; + const key = String(workspaceRoot).toLowerCase(); + if (!_instances.has(key)) { + _instances.set(key, new TaskDatabase(workspaceRoot)); + } + return _instances.get(key); +} + +function closeTaskDatabase(workspaceRoot) { + if (!workspaceRoot) return; + const key = String(workspaceRoot).toLowerCase(); + const db = _instances.get(key); + if (db) { db.close(); _instances.delete(key); } +} + +module.exports = { TaskDatabase, getTaskDatabase, closeTaskDatabase, hashSource }; diff --git a/electron/lib/tasks/taskIpc.cjs b/electron/lib/tasks/taskIpc.cjs new file mode 100644 index 00000000..db1cbb64 --- /dev/null +++ b/electron/lib/tasks/taskIpc.cjs @@ -0,0 +1,428 @@ +'use strict'; + +const { getTaskDatabase } = require('./TaskDatabase.cjs'); +const { assertTrustedIpcSender } = require('../ipc/ipcSecurity.cjs'); +const path = require('node:path'); +const fs = require('node:fs'); + +// Parse open/closed task lines from markdown text +const OPEN_REGEX = /^[ \t]*[-*+]?[ \t]*\[ \][ \t]+(.+)$/gm; +const DONE_REGEX = /^[ \t]*[-*+]?[ \t]*\[(?:x|X)\][ \t]+(.+)$/gm; + +function parseMarkdownTasks(content) { + const tasks = []; + const src = String(content || ''); + + const tryRegex = (re, status) => { + re.lastIndex = 0; + let m; + while ((m = re.exec(src)) !== null) { + const line = src.slice(0, m.index).split(/\r?\n/).length; + const lineText = m[0].trim(); + tasks.push({ title: (m[1] || '').trim(), status, line, lineText }); + } + }; + + tryRegex(OPEN_REGEX, 'open'); + tryRegex(DONE_REGEX, 'done'); + return tasks.sort((a, b) => a.line - b.line); +} + +/** + * Recursively walk workspace root and sync all markdown tasks into TaskDatabase. + */ +function syncAllWorkspaceNotes(notesRoot, db) { + if (!notesRoot || !fs.existsSync(notesRoot) || !db) return; + try { + const walk = (dir) => { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.markdown'))) { + try { + const content = fs.readFileSync(fullPath, 'utf8'); + const parsed = parseMarkdownTasks(content); + db.syncFromNote(fullPath, parsed); + } catch { /* ignore single file error */ } + } + } + }; + walk(notesRoot); + } catch (err) { + console.error('[taskIpc] syncAllWorkspaceNotes error:', err.message); + } +} + +/** + * Append a task line to a note file if not already present. + */ +function appendTaskToNote(filePath, title) { + if (!filePath || !title) return null; + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + let content = ''; + if (fs.existsSync(filePath)) { + content = fs.readFileSync(filePath, 'utf8'); + } + const lines = content ? content.split(/\r?\n/) : []; + const taskLineText = `- [ ] ${title.trim()}`; + + // Append newline if content doesn't end with one + if (lines.length > 0 && lines[lines.length - 1] !== '') { + lines.push(''); + } + lines.push(taskLineText); + fs.writeFileSync(filePath, lines.join('\n'), 'utf8'); + return { line: lines.length, lineText: taskLineText }; + } catch (err) { + console.error('[taskIpc] appendTaskToNote failed:', err.message); + return null; + } +} + +/** + * Write [x] or [ ] back to a note file when a task is completed via the UI. + */ +function writeTaskStatusToNote(filePath, sourceLine, newStatus, taskTitle) { + if (!filePath || !fs.existsSync(filePath)) return false; + try { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + const lineIdx = (sourceLine ?? 1) - 1; + + let updatedLine = null; + let updatedIdx = -1; + + // 1. Check exact line number + if (lineIdx >= 0 && lineIdx < lines.length) { + const line = lines[lineIdx]; + if (newStatus === 'done' && /\[[ ]\]/.test(line)) { + updatedLine = line.replace(/\[[ ]\]/, '[x]'); + updatedIdx = lineIdx; + } else if ((newStatus === 'open' || newStatus === 'in_progress') && /\[[xX]\]/.test(line)) { + updatedLine = line.replace(/\[[xX]\]/, '[ ]'); + updatedIdx = lineIdx; + } + } + + // 2. Fallback: Search lines for task title match + if (updatedIdx < 0 && taskTitle) { + const cleanTitle = String(taskTitle).trim().toLowerCase(); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.toLowerCase().includes(cleanTitle)) { + if (newStatus === 'done' && /\[[ ]\]/.test(line)) { + updatedLine = line.replace(/\[[ ]\]/, '[x]'); + updatedIdx = i; + break; + } else if ((newStatus === 'open' || newStatus === 'in_progress') && /\[[xX]\]/.test(line)) { + updatedLine = line.replace(/\[[xX]\]/, '[ ]'); + updatedIdx = i; + break; + } + } + } + } + + if (updatedIdx < 0 || !updatedLine) return false; + + lines[updatedIdx] = updatedLine; + fs.writeFileSync(filePath, lines.join('\n'), 'utf8'); + return true; + } catch (err) { + console.error('[taskIpc] writeTaskStatusToNote failed:', err.message); + return false; + } +} + +/** + * Cross-workspace persons cache stored in appData/persons.json for auto-tagging suggestions. + */ +function loadAppDataPersons(appDataDir) { + const p = path.join(appDataDir, 'persons.json'); + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return []; } +} + +function saveAppDataPersons(appDataDir, persons) { + try { + const p = path.join(appDataDir, 'persons.json'); + fs.writeFileSync(p, JSON.stringify(persons, null, 2), 'utf8'); + } catch { /* ignore */ } +} + +function registerTaskIpc(ipcMain, deps) { + const { BrowserWindow, getNotesRoot, getActiveProject, getAppDataDir } = deps; + + function trusted(channel, handler) { + ipcMain.handle(channel, (event, payload) => { + assertTrustedIpcSender(BrowserWindow, event, channel); + return handler(event, payload); + }); + } + + function getRoot() { + const project = getActiveProject?.(); + return project?.rootPath || getNotesRoot?.() || ''; + } + + function getDb() { + const root = getRoot(); + return getTaskDatabase(root); + } + + // ── Sync ────────────────────────────────────────────────────────────────── + + trusted('tasks:sync-from-note', (_event, { filePath, content }) => { + const db = getDb(); + if (!db) return { inserted: 0, updated: 0 }; + const parsed = parseMarkdownTasks(content); + return db.syncFromNote(filePath, parsed); + }); + + trusted('tasks:sync-all', (_event) => { + const db = getDb(); + const root = getRoot(); + if (!db || !root) return false; + syncAllWorkspaceNotes(root, db); + return true; + }); + + // ── CRUD ────────────────────────────────────────────────────────────────── + + trusted('tasks:list', (_event, filters = {}) => { + const db = getDb(); + const root = getRoot(); + if (db && root) { + // Auto-scan workspace markdown notes on list query so all notes' tasks are up-to-date + syncAllWorkspaceNotes(root, db); + } + return db ? db.listTasks(filters) : []; + }); + + trusted('tasks:get', (_event, { id }) => { + const db = getDb(); + return db ? db.getTask(id) : null; + }); + + trusted('tasks:create', (_event, payload) => { + const db = getDb(); + const root = getRoot(); + if (!db) return null; + + let targetFile = payload.sourcePath; + if (payload.standalone || payload.sourcePath === "none") { + targetFile = null; + } else if (!targetFile && root) { + targetFile = path.join(root, 'Tasks.md'); + } + + let appendedLine = null; + if (targetFile && payload.title) { + appendedLine = appendTaskToNote(targetFile, payload.title); + } + + const task = db.createTask({ + ...payload, + sourcePath: targetFile || null, + sourceLine: appendedLine?.line || null, + }); + + return task; + }); + + trusted('tasks:update', (_event, { id, ...fields }) => { + const db = getDb(); + if (!db) return null; + const task = db.getTask(id); + if (!task) return null; + const updated = db.updateTask(id, fields); + if (fields.status && task.source_path) { + writeTaskStatusToNote(task.source_path, task.source_line, fields.status, task.title); + } + return updated; + }); + + trusted('tasks:complete', (_event, { id, status = 'done' }) => { + const db = getDb(); + if (!db) return null; + + const task = db.getTask(id); + if (!task) return null; + + const updated = db.updateTask(id, { status }); + + // Two-way sync: write back to source note + if (task.source_path) { + writeTaskStatusToNote(task.source_path, task.source_line, status, task.title); + } + + return updated; + }); + + trusted('tasks:delete', (_event, { id }) => { + const db = getDb(); + return db ? db.deleteTask(id) : false; + }); + + trusted('tasks:get-overdue', () => { + const db = getDb(); + const root = getRoot(); + if (db && root) { + syncAllWorkspaceNotes(root, db); + } + return db ? db.getOverdueTasks() : []; + }); + + // ── Comments ────────────────────────────────────────────────────────────── + + const handleAddComment = (_event, { taskId, body, author }) => { + const db = getDb(); + return db ? db.addComment(taskId, { body, author }) : null; + }; + + const handleGetComments = (_event, { taskId }) => { + const db = getDb(); + return db ? db.getComments(taskId) : []; + }; + + trusted('tasks:add-comment', handleAddComment); + trusted('tasks:get-comments', handleGetComments); + trusted('tasks:comments:add', handleAddComment); + trusted('tasks:comments:list', handleGetComments); + + // ── Persons ─────────────────────────────────────────────────────────────── + + trusted('persons:list', (_event) => { + const db = getDb(); + const workspacePersons = db ? db.listPersons() : []; + const appDataPersons = loadAppDataPersons(getAppDataDir?.() || ''); + + // Merge by id/name + const map = new Map(); + for (const p of appDataPersons) map.set(p.name.toLowerCase(), p); + for (const p of workspacePersons) map.set(p.name.toLowerCase(), p); + return Array.from(map.values()); + }); + + trusted('persons:upsert', (_event, payload) => { + const db = getDb(); + const person = db ? db.upsertPerson(payload) : null; + + // Cache in appData + if (person && getAppDataDir?.()) { + const all = loadAppDataPersons(getAppDataDir()); + const idx = all.findIndex(p => p.id === person.id || p.name.toLowerCase() === person.name.toLowerCase()); + if (idx !== -1) all[idx] = person; + else all.push(person); + saveAppDataPersons(getAppDataDir(), all); + } + + return person; + }); + + trusted('persons:delete', (_event, { id }) => { + const db = getDb(); + return db ? db.deletePerson(id) : false; + }); + + // ── Calendar ────────────────────────────────────────────────────────────── + + trusted('calendar:get-events', (_event, { startDate, endDate, types = [] } = {}) => { + const db = getDb(); + const root = getRoot(); + const events = []; + + if (db && root) { + // Sync notes first so calendar events match all workspace markdown notes + syncAllWorkspaceNotes(root, db); + + // Task events + const taskEvents = db.getCalendarTaskEvents(startDate, endDate); + for (const t of taskEvents) { + if (!types.length || types.includes(t._eventType)) { + events.push({ + id: `task-${t.id}`, + title: t.title, + start: t.scheduled_start || t.due_date || t.completed_at, + end: t.scheduled_end || t.due_date || t.completed_at, + allDay: Boolean(t.isAllDay), + type: t._eventType, + taskId: t.id, + sourcePath: t.source_path, + priority: t.priority, + status: t.status, + }); + } + } + } + + // Note events directly from workspace filesystem notes + if (root) { + try { + const walkNotes = (dir) => { + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkNotes(fullPath); + } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.markdown'))) { + try { + const stat = fs.statSync(fullPath); + const title = entry.name.replace(/\.md$/i, ''); + const createdStr = (stat.birthtime && !isNaN(stat.birthtime.getTime()) ? stat.birthtime : stat.ctime).toISOString(); + const updatedStr = stat.mtime.toISOString(); + const createdDate = createdStr.slice(0, 10); + const updatedDate = updatedStr.slice(0, 10); + + if (createdDate >= startDate && createdDate <= endDate) { + if (!types.length || types.includes('note-created')) { + events.push({ + id: `note-created-${fullPath}`, + title: `Created: ${title}`, + start: createdStr, + end: createdStr, + allDay: true, + type: 'note-created', + sourcePath: fullPath, + filePath: fullPath, + }); + } + } + + if (updatedDate >= startDate && updatedDate <= endDate) { + if (!types.length || types.includes('note-updated')) { + events.push({ + id: `note-updated-${fullPath}`, + title: `Updated: ${title}`, + start: updatedStr, + end: updatedStr, + allDay: true, + type: 'note-updated', + sourcePath: fullPath, + filePath: fullPath, + }); + } + } + } catch { /* ignore single stat error */ } + } + } + }; + walkNotes(root); + } catch (err) { + console.error('[taskIpc] calendar note events failed:', err.message); + } + } + + return events; + }); +} + +module.exports = { registerTaskIpc, parseMarkdownTasks, writeTaskStatusToNote }; diff --git a/electron/lib/web/websiteRenderer.cjs b/electron/lib/web/websiteRenderer.cjs index 72c0c526..f15ffc1c 100644 --- a/electron/lib/web/websiteRenderer.cjs +++ b/electron/lib/web/websiteRenderer.cjs @@ -23,6 +23,25 @@ function createWebsiteRenderer(deps) { getNotesRoot } = deps; +function processCallouts(html) { + if (!html) return ""; + return html.replace( + /
\s*

\s*\[!(NOTE|WARNING|TIP|TODO|IMPORTANT|CAUTION)\][ \t]*(.*?)(?:|\n)?([\s\S]*?)<\/p>\s*<\/blockquote>/gi, + (_match, type, title, rest) => { + const calloutType = type.toLowerCase(); + const displayTitle = title.trim() || type; + const icons = { note: "πŸ“", warning: "⚠️", tip: "πŸ’‘", todo: "β˜‘οΈ", important: "ℹ️", caution: "🚨" }; + const icon = icons[calloutType] || "πŸ“Œ"; + const bodyHtml = rest.trim() ? `

${rest.trim()}

` : ""; + return `
+
${icon} ${escapeCodeHtml(displayTitle)}
+
${bodyHtml}
+
`; + } + ); +} + + function escapeCodeHtml(value) { return String(value || "") .replace(/&/g, "&") diff --git a/electron/main.cjs b/electron/main.cjs index bddc02ce..9dee0996 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -45,7 +45,7 @@ const { initializeAIHandlers } = require("./ai/aiHandlers.cjs"); const { registerGitIpcHandlers } = require("./lib/git/gitIpc.cjs"); const gitService = require("./lib/git/gitService.cjs"); const { registerNotePackageIpc } = require("./lib/export/notePackageIpc.cjs"); - +const { registerTaskIpc } = require("./lib/tasks/taskIpc.cjs"); const rendererUrl = process.env.ELECTRON_RENDERER_URL; const projectRoot = app.getAppPath(); @@ -1271,3 +1271,11 @@ registerGitIpcHandlers(ipcMain, { BrowserWindow, getNotesRoot: () => notesRoot, }); + +registerTaskIpc(ipcMain, { + BrowserWindow, + getNotesRoot: () => notesRoot, + getActiveProject, + getMetadataStore: () => metadataStore, + getAppDataDir: () => appDataDir, +}); diff --git a/electron/preload.cjs b/electron/preload.cjs index b0e8fecc..04a01689 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -358,5 +358,20 @@ contextBridge.exposeInMainWorld("notesApi", { openExternal: (url) => ipcRenderer.invoke("shell:open-external", { url }), executeTool: (payload) => ipcRenderer.invoke("tool:execute", payload), listTools: () => ipcRenderer.invoke("tool:list"), + + // ── Tasks & Calendar ───────────────────────────────────────────────────── + syncTasksFromNote: (p) => ipcRenderer.invoke("tasks:sync-from-note", p), + listTasks: (p) => ipcRenderer.invoke("tasks:list", p), + getTask: (p) => ipcRenderer.invoke("tasks:get", p), + createTask: (p) => ipcRenderer.invoke("tasks:create", p), + updateTask: (p) => ipcRenderer.invoke("tasks:update", p), + completeTask: (p) => ipcRenderer.invoke("tasks:complete", p), + deleteTask: (p) => ipcRenderer.invoke("tasks:delete", p), + addTaskComment: (p) => ipcRenderer.invoke("tasks:add-comment", p), + getTaskComments: (p) => ipcRenderer.invoke("tasks:get-comments", p), + getCalendarEvents: (p) => ipcRenderer.invoke("calendar:get-events", p), + listPersons: () => ipcRenderer.invoke("persons:list"), + upsertPerson: (p) => ipcRenderer.invoke("persons:upsert", p), + deletePerson: (p) => ipcRenderer.invoke("persons:delete", p), }); diff --git a/package-lock.json b/package-lock.json index 95c2b208..c7714ca2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notely", - "version": "0.1.32", + "version": "0.1.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notely", - "version": "0.1.32", + "version": "0.1.37", "hasInstallScript": true, "license": "CC-BY-NC-4.0", "dependencies": { @@ -41,6 +41,7 @@ "@xyflow/react": "^12.11.1", "concurrently": "^9.1.2", "dagre": "^0.8.5", + "date-fns": "^4.4.0", "dictionary-en-us": "^2.2.1", "electron": "^37.2.0", "electron-builder": "^24.13.3", @@ -60,6 +61,7 @@ "prettier": "^3.4.0", "rcedit": "^5.0.2", "react": "^19.1.0", + "react-big-calendar": "^1.20.0", "react-dom": "^19.1.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", @@ -4422,6 +4424,17 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -4959,6 +4972,19 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -5963,6 +5989,16 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -5996,6 +6032,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/warning": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz", + "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -7803,6 +7846,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cldrjs": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/cldrjs/-/cldrjs-0.5.5.tgz", + "integrity": "sha512-KDwzwbmLIPfCgd8JERVDpQKrUUM1U4KpFJJg2IROv89rF172lLufoJnqJ/Wea6fXL5bO6WjuLMzY8V52UWPvkA==", + "dev": true + }, "node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", @@ -8812,6 +8861,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-arithmetic": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz", + "integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -9155,6 +9222,17 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz", + "integrity": "sha512-IKySryuFwseGkrCA/pIqlwUPOD50w1Lj/B2Yief3vBOP18k5y4t+hTqKh55gULDVeJMRitcozve+g/wVFf4sFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "csstype": "^3.1.3" + } + }, "node_modules/dompurify": { "version": "3.4.11", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", @@ -10634,6 +10712,16 @@ "node": ">=10" } }, + "node_modules/globalize": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/globalize/-/globalize-1.7.1.tgz", + "integrity": "sha512-PFymRL0PtitFOlSniuwwwNfkooi3cLQJo9Uke1+j1DsGfUkkHkwneImqVtGcqKI0TuzhAlHt7hAcgK324902HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cldrjs": "^0.5.4" + } + }, "node_modules/globals": { "version": "15.14.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", @@ -11184,6 +11272,16 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -12274,6 +12372,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -12747,6 +12855,13 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -13585,6 +13700,29 @@ "node": ">=10" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -14695,6 +14833,45 @@ "node": ">=0.10.0" } }, + "node_modules/react-big-calendar": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.20.0.tgz", + "integrity": "sha512-Lp1mvG34l/9xtb/2LsBb4UAF3iPcUkmDqbmpsvDHzp/n8GA5gtn3+nf8BsULWw08opKDgv38nQi75dQlOOqzkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "clsx": "^2.1.1", + "date-arithmetic": "^4.1.0", + "dayjs": "^1.11.21", + "dom-helpers": "^6.0.1", + "globalize": "^1.7.1", + "invariant": "^2.2.4", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "luxon": "^3.7.2", + "memoize-one": "^6.0.0", + "moment": "^2.29.4", + "moment-timezone": "^0.5.48", + "prop-types": "^15.8.1", + "react-overlays": "^5.2.1", + "uncontrollable": "^7.2.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17 || ^18 || ^19", + "react-dom": "^16.14.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-big-calendar/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react-dom": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", @@ -14715,6 +14892,45 @@ "dev": true, "license": "MIT" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-overlays": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz", + "integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.8", + "@popperjs/core": "^2.11.6", + "@restart/hooks": "^0.4.7", + "@types/warning": "^3.0.0", + "dom-helpers": "^5.2.0", + "prop-types": "^15.7.2", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-overlays/node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -16810,6 +17026,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -18998,6 +19230,16 @@ "node": ">=12.0.0" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index c32f4d6f..0bbc8919 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@xyflow/react": "^12.11.1", "concurrently": "^9.1.2", "dagre": "^0.8.5", + "date-fns": "^4.4.0", "dictionary-en-us": "^2.2.1", "electron": "^37.2.0", "electron-builder": "^24.13.3", @@ -87,6 +88,7 @@ "prettier": "^3.4.0", "rcedit": "^5.0.2", "react": "^19.1.0", + "react-big-calendar": "^1.20.0", "react-dom": "^19.1.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", diff --git a/src/App.jsx b/src/App.jsx index dc6f7142..637abfc4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -24,6 +24,9 @@ const EmbeddingsPage = lazy(() => import("./components/EmbeddingsPage")); const AIPersonasManager = lazy(() => import("./components/AIPersonasManager")); const AIHealthPage = lazy(() => import("./components/AIHealthPage")); const AppLogsPage = lazy(() => import("./components/AppLogsPage")); +const TaskWorkspacePage = lazy(() => import("./components/TaskWorkspacePage").then((m) => ({ default: m.TaskWorkspacePage }))); +const CalendarPage = lazy(() => import("./components/CalendarPage").then((m) => ({ default: m.CalendarPage }))); + import { SettingsModal } from "./components/SettingsModal"; import { WorkspaceModal } from "./components/WorkspaceModal"; @@ -52,12 +55,7 @@ import { GitStatusBar } from "./components/GitStatusBar"; import { AIStatusBar } from "./components/AIStatusBar"; import NotePreviewModal from "./components/NotePreviewModal"; -const TasksPanel = lazy(() => - import("./components/TasksPanel").then((m) => ({ default: m.TasksPanel })) -); -const AllTasksPanel = lazy(() => - import("./components/AllTasksPanel").then((m) => ({ default: m.AllTasksPanel })) -); + const NoteListPanel = lazy(() => import("./components/NoteListPanel").then((m) => ({ default: m.NoteListPanel })) ); @@ -361,11 +359,12 @@ export default function App() { healthPageOpen, setHealthPageOpen, appLogsOpen, setAppLogsOpen, globalCommitDialogOpen, setGlobalCommitDialogOpen, - tasksPanelOpen, setTasksPanelOpen, - allTasksPanelOpen, setAllTasksPanelOpen, recentNotesPanelOpen, setRecentNotesPanelOpen, favoritesPanelOpen, setFavoritesPanelOpen, trashDialogOpen, setTrashDialogOpen, + taskWorkspaceOpen, setTaskWorkspaceOpen, + taskWorkspaceContext, setTaskWorkspaceContext, + calendarPageOpen, setCalendarPageOpen, onboardingComplete, setOnboardingCompleteState, defaultNotesPath, setDefaultNotesPath, themePreference, setThemePreferenceState, @@ -1604,8 +1603,23 @@ export default function App() { setHealthPageOpen(false); setAppLogsOpen(false); setGitVCOpen(false); + setTaskWorkspaceOpen(false); + setCalendarPageOpen(false); }; + if (action === "open-tasks-workspace") { + closeAllFullscreenViews(); + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + return; + } + + if (action === "open-calendar") { + closeAllFullscreenViews(); + setCalendarPageOpen(true); + return; + } + if (action === "open-workspace-graph") { closeAllFullscreenViews(); setGraphPanelOpen(true); @@ -1704,7 +1718,28 @@ export default function App() { + if (action === "tasks-workspace") { + closeAllFullscreenViews(); + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + return; + } + + if (action === "tasks-overdue") { + closeAllFullscreenViews(); + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + return; + } + + if (action === "calendar") { + closeAllFullscreenViews(); + setCalendarPageOpen(true); + return; + } + if (action === "open-p2p-sync-help") { + setP2PSyncHelpOpen(true); return; } @@ -2569,13 +2604,9 @@ export default function App() { return; } - if (resolvedCommandId === "open-tasks-panel") { - setTasksPanelOpen(true); - return; - } - - if (resolvedCommandId === "open-all-tasks") { - setAllTasksPanelOpen(true); + if (resolvedCommandId === "open-tasks-panel" || resolvedCommandId === "open-all-tasks") { + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); return; } @@ -2726,6 +2757,17 @@ export default function App() { } function handleDashboardAction(action) { + if (action === "tasks-workspace" || action === "tasks-overdue") { + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + return; + } + + if (action === "calendar") { + setCalendarPageOpen(true); + return; + } + if (action === "new-note") { setNoteDialogOpen(true); return; @@ -2776,6 +2818,12 @@ export default function App() { return [...currentFavorites, filePath]; }); } + + const handleOpenAllTasks = useCallback((notePath) => { + setTaskWorkspaceContext(notePath ? { noteFilter: notePath } : null); + setTaskWorkspaceOpen(true); + }, [setTaskWorkspaceContext, setTaskWorkspaceOpen]); + const rootPath = activeProject?.rootPath || notesFolderPath || ""; const currentLandingPath = landingFolderPath || rootPath; const normalizedRootPath = String(rootPath || "").replace(/[\\/]+$/, ""); @@ -3090,7 +3138,10 @@ export default function App() { }} onOpenListItem={handleOpenListItem} onOpenReferencedDocument={(task) => handleOpenReferencedDocument(task?.filePath)} - onOpenAllTasks={() => setAllTasksPanelOpen(true)} + onOpenAllTasks={() => { + setTaskWorkspaceContext(null); + setTaskWorkspaceOpen(true); + }} onOpenRecentNotes={() => setRecentNotesPanelOpen(true)} onOpenFavorites={() => setFavoritesPanelOpen(true)} onDashboardAction={handleDashboardAction} @@ -3176,6 +3227,7 @@ export default function App() { menuAction={documentMenuAction} onNotify={notify} onBack={handleGoHome} + onOpenAllTasks={handleOpenAllTasks} breadcrumbs={noteBreadcrumbSegments} onNavigateBreadcrumb={async (targetPath) => { const didLeave = await handleGoHome(); @@ -3670,16 +3722,7 @@ export default function App() { ) : null} - {tasksPanelOpen ? ( - - setTasksPanelOpen(false)} - onOpenNote={(group) => handleOpenReferencedDocument(group?.filePath)} - /> - - ) : null} + {recentNotesPanelOpen ? ( @@ -3709,16 +3752,7 @@ export default function App() { ) : null} - {allTasksPanelOpen ? ( - - setAllTasksPanelOpen(false)} - onOpenNote={(group) => handleOpenReferencedDocument(group?.filePath)} - /> - - ) : null} + @@ -3953,6 +3987,41 @@ export default function App() { )} + {taskWorkspaceOpen && ( +
+ Loading Task Workspace…
}> + setTaskWorkspaceOpen(false)} + onOpenNote={(filePath) => { + setTaskWorkspaceOpen(false); + void handleOpenReferencedDocument(filePath); + }} + noteFilter={taskWorkspaceContext?.noteFilter ?? null} + /> + + + )} + + {calendarPageOpen && ( +
+ Loading Calendar…
}> + setCalendarPageOpen(false)} + onOpenNote={(filePath) => { + setCalendarPageOpen(false); + void handleOpenReferencedDocument(filePath); + }} + onOpenTask={(task) => { + setCalendarPageOpen(false); + setTaskWorkspaceContext(task?.source_path ? { noteFilter: task.source_path } : null); + setTaskWorkspaceOpen(true); + }} + /> + + + )} + + extractTasksFromDocuments(documents), [documents]); - - const filteredTasks = useMemo(() => { - const needle = filter.trim().toLowerCase(); - return allTasks.filter((task) => { - if (statusFilter !== "all" && task.status !== statusFilter) return false; - if (!needle) return true; - return task.text.toLowerCase().includes(needle) || task.noteTitle.toLowerCase().includes(needle); - }); - }, [allTasks, filter, statusFilter]); - - const groups = useMemo(() => groupTasksByNote(filteredTasks), [filteredTasks]); - - if (!isOpen) return null; - - return ( - -
-
- -

All Tasks

- {filteredTasks.length} -
- -
- -
-
- - setFilter(e.target.value)} - placeholder="Filter tasks or notes…" - aria-label="Filter all tasks" - className="tasks-panel-filter-input" - /> -
-
- - - -
-
- -
- {!allTasks.length ? ( -
-

No tasks found.

-

Add - [ ] task text or - [x] done task to notes to track them here.

-
- ) : !filteredTasks.length ? ( -
-

No tasks match your current filters.

-
- ) : ( - groups.map((group) => ( -
-
- {group.noteTitle} - -
-
    - {group.tasks.map((task) => ( -
  • - - {task.text} - {task.status === "closed" ? "Closed" : "Open"} -
  • - ))} -
-
- )) - )} -
-
- ); -} \ No newline at end of file diff --git a/src/components/CalendarPage.jsx b/src/components/CalendarPage.jsx new file mode 100644 index 00000000..b3539a76 --- /dev/null +++ b/src/components/CalendarPage.jsx @@ -0,0 +1,316 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Calendar, dateFnsLocalizer } from "react-big-calendar"; +import { format, parse, startOfWeek, getDay, startOfMonth, endOfMonth, addMonths, subMonths } from "date-fns"; +import { enUS } from "date-fns/locale"; +import { + ChevronLeft, ChevronRight, Calendar as CalendarIcon, + CheckCircle2, AlertTriangle, FileText, Clock, +} from "lucide-react"; +import { getCalendarEvents } from "../services/electronService"; +import "react-big-calendar/lib/css/react-big-calendar.css"; +import "../styles/CalendarPage.css"; + +const locales = { "en-US": enUS }; +const localizer = dateFnsLocalizer({ format, parse, startOfWeek, getDay, locales }); + +// ── Event type config ────────────────────────────────────────────────────── + +const EVENT_TYPE_META = { + "note-created": { label: "Note Created", className: "cal-event-note-created", Icon: FileText }, + "note-updated": { label: "Note Updated", className: "cal-event-note-updated", Icon: FileText }, + "task-due": { label: "Task Due", className: "cal-event-task-due", Icon: Clock }, + "task-scheduled": { label: "Task Scheduled", className: "cal-event-task-sched", Icon: Clock }, + "task-completed": { label: "Task Completed", className: "cal-event-task-done", Icon: CheckCircle2 }, + "task-overdue": { label: "Task Overdue", className: "cal-event-task-overdue", Icon: AlertTriangle }, +}; + +function buildRbcEvents(rawResult) { + const events = []; + + let taskList = []; + let noteList = []; + + if (Array.isArray(rawResult)) { + for (const item of rawResult) { + if (item.type?.startsWith("note") || item._eventType?.startsWith("note")) { + noteList.push(item); + } else { + taskList.push(item); + } + } + } else if (rawResult && typeof rawResult === "object") { + taskList = Array.isArray(rawResult.taskEvents) ? rawResult.taskEvents : (Array.isArray(rawResult.tasks) ? rawResult.tasks : []); + noteList = Array.isArray(rawResult.noteEvents) ? rawResult.noteEvents : (Array.isArray(rawResult.notes) ? rawResult.notes : []); + if (!taskList.length && !noteList.length && Array.isArray(rawResult.events)) { + for (const item of rawResult.events) { + if (item.type?.startsWith("note") || item._eventType?.startsWith("note")) noteList.push(item); + else taskList.push(item); + } + } + } + + for (const task of taskList) { + const type = task.type || task._eventType || (task.due_date ? "task-due" : "task-scheduled"); + const meta = EVENT_TYPE_META[type] ?? EVENT_TYPE_META["task-due"]; + + let start = task.start + ? new Date(task.start) + : task.scheduled_start + ? new Date(task.scheduled_start) + : task.due_date + ? new Date(task.due_date + "T00:00:00") + : null; + if (!start) continue; + + let end = task.end + ? new Date(task.end) + : task.scheduled_end + ? new Date(task.scheduled_end) + : new Date(start.getTime() + 60 * 60000); + + events.push({ + id: task.id || `task-${Math.random()}`, + title: task.title || "Task", + start, + end, + allDay: Boolean(task.allDay || task.is_all_day || (!task.scheduled_start && task.due_date)), + resource: { ...task, _eventType: type }, + className: meta.className, + }); + } + + for (const note of noteList) { + const type = note.type || note._eventType || "note-updated"; + const startStr = note.start || note.updatedAt || note.created_at || note.createdAt; + if (!startStr) continue; + + const start = new Date(startStr); + const end = note.end ? new Date(note.end) : new Date(start.getTime() + 30 * 60000); + const meta = EVENT_TYPE_META[type] ?? EVENT_TYPE_META["note-updated"]; + + events.push({ + id: note.id || `note-${Math.random()}`, + title: note.title || "Note", + start, + end, + allDay: Boolean(note.allDay ?? true), + resource: { ...note, _eventType: type }, + className: meta.className, + }); + } + + return events; +} + +function EventTypeToggle({ type, active, onToggle }) { + const meta = EVENT_TYPE_META[type]; + if (!meta) return null; + const Icon = meta.Icon; + return ( + + ); +} + +function EventCard({ event, onOpenNote, onOpenTask, onClose }) { + const type = event.resource?._eventType ?? ""; + const meta = EVENT_TYPE_META[type] ?? {}; + const Icon = meta.Icon ?? CalendarIcon; + const task = type?.startsWith("task") ? event.resource : null; + const note = type?.startsWith("note") ? event.resource : null; + + return ( +
+
+ {meta.label} + +
+
{event.title}
+ {event.start && ( +
+ {event.allDay ? format(event.start, "MMM d, yyyy") : format(event.start, "MMM d, yyyy 'at' h:mm a")} + {event.end && !event.allDay && ` – ${format(event.end, "h:mm a")}`} +
+ )} +
+ {task && ( + + )} + {note && ( + + )} + {task?.source_path && ( + + )} +
+
+ ); +} + +export function CalendarPage({ onBack, onOpenNote, onOpenTask }) { + const [date, setDate] = useState(new Date()); + const [view, setView] = useState("month"); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(null); + const [popupPos, setPopupPos] = useState({ top: 0, left: 0 }); + const [activeFilters, setActiveFilters] = useState(new Set(Object.keys(EVENT_TYPE_META))); + const [error, setError] = useState(null); + + const loadEvents = useCallback(async (currentDate) => { + setLoading(true); + setError(null); + try { + const start = startOfMonth(currentDate); + const end = endOfMonth(currentDate); + // Pad a week on each side so the calendar grid edges are covered + const padStart = new Date(start.getTime() - 7 * 24 * 60 * 60000); + const padEnd = new Date(end.getTime() + 7 * 24 * 60 * 60000); + const result = await getCalendarEvents( + format(padStart, "yyyy-MM-dd"), + format(padEnd, "yyyy-MM-dd") + ); + const built = buildRbcEvents(result); + setEvents(built); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void loadEvents(date); }, [date, loadEvents]); + + const toggleFilter = useCallback(type => { + setActiveFilters(prev => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); else next.add(type); + return next; + }); + }, []); + + const visibleEvents = events.filter(e => activeFilters.has(e.resource?._eventType)); + + const handleSelectEvent = useCallback((event, syntheticEvent) => { + const rect = syntheticEvent?.target?.getBoundingClientRect?.(); + if (rect) { + setPopupPos({ top: rect.bottom + 8, left: Math.min(rect.left, window.innerWidth - 320) }); + } else { + setPopupPos({ top: 120, left: 80 }); + } + setSelectedEvent(event); + }, []); + + const eventPropGetter = useCallback(event => ({ + className: `cal-rbc-event ${event.className ?? ""}`, + }), []); + + return ( +
+ {/* App-Standard Topbar Navigation */} +
+ + +
+ {/* Month Navigator */} +
+ + {format(date, "MMMM yyyy")} + + +
+ +
+ + {visibleEvents.length} events +
+ + {/* View mode toggle: Month vs Week vs Day */} +
+ {["month", "week", "day"].map(v => ( + + ))} +
+
+
+ + {/* Filter bar */} +
+ {Object.keys(EVENT_TYPE_META).map(type => ( + + ))} + {loading && Loading…} +
+ + {/* Calendar */} +
+ {error &&
{error}
} + +
+ + {/* Event popup */} + {selectedEvent && ( + <> +
setSelectedEvent(null)} /> +
+ setSelectedEvent(null)} + /> +
+ + )} +
+ ); +} diff --git a/src/components/DashboardPanels.jsx b/src/components/DashboardPanels.jsx index 2681285e..80e159e9 100644 --- a/src/components/DashboardPanels.jsx +++ b/src/components/DashboardPanels.jsx @@ -1,7 +1,8 @@ -import { ArrowRight, CheckSquare, Clock3, FilePlus2, FolderPlus, Image as ImageIcon, Search, Trash2, FileText, Star, Sparkles } from "lucide-react"; -import { useMemo } from "react"; +import { ArrowRight, CheckSquare, Clock3, FilePlus2, FolderPlus, Image as ImageIcon, Search, Trash2, FileText, Star, Sparkles, Calendar, AlertTriangle } from "lucide-react"; +import { useMemo, useEffect, useState } from "react"; import { formatDate } from "../utils/dateUtils"; import { extractOpenTasksFromDocuments, getTaskCountsFromDocuments } from "../utils/taskUtils"; +import { listTasks } from "../services/electronService"; const DASHBOARD_SECTION_LIMIT = 3; @@ -82,6 +83,14 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, ); } + const [overdueCount, setOverdueCount] = useState(0); + + useEffect(() => { + listTasks({ status: "overdue" }) + .then(tasks => setOverdueCount(Array.isArray(tasks) ? tasks.length : 0)) + .catch(() => setOverdueCount(0)); + }, []); + if (loading) return null; if (layout === "rail") { @@ -101,6 +110,12 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading, + + @@ -190,6 +205,18 @@ export function DashboardPanels({ documents, taskDocuments = documents, loading,

Open Tasks

+ {overdueCount > 0 && ( + + )} {taskCounts.total > 0 && ( +
) : null} +
+ +
)} @@ -1623,6 +1645,7 @@ export function DocumentDetail({ onTableEditorToggle={onTableEditorToggle} scrollSyncEnabled={scrollSyncEnabled} onScrollSyncEnabledChange={onScrollSyncEnabledChange} + onOpenTaskDetails={(taskInfo) => setSelectedTaskForModal({ ...taskInfo, noteContent: content })} /> @@ -1767,6 +1790,40 @@ export function DocumentDetail({ ) : null} + setSelectedTaskForModal(null)} + taskInfo={selectedTaskForModal} + onOpenNote={(targetPath) => { + setSelectedTaskForModal(null); + onOpenDocument?.(targetPath); + }} + onNotify={onNotify} + onTaskUpdated={async (updatedTask) => { + // 1. Save active note buffer first if dirty so no unsaved text edits are lost + if (dirty) { + try { + await savePreservingEditorViewport({ reason: "pre-task-update-save", silent: true }); + } catch (saveErr) { + console.warn("[DocumentDetail] Pre-task-update save warning:", saveErr); + } + } + + // 2. Clear disk warning flag and reload updated note content from disk without prompt + setChangedOnDisk(false); + const targetPath = updatedTask?.source_path || updatedTask?.sourcePath || document.filePath; + if (targetPath && targetPath === document.filePath) { + try { + if (typeof onOpenDocument === "function") { + await onOpenDocument(targetPath, { forceReload: true, preserveActiveTab: true }); + } else if (typeof onReloadFromDisk === "function") { + await onReloadFromDisk(targetPath); + } + } catch { /* ignore reload error */ } + } + }} + /> + ); } diff --git a/src/components/EditorPane.jsx b/src/components/EditorPane.jsx index f54598d4..d35a62b3 100644 --- a/src/components/EditorPane.jsx +++ b/src/components/EditorPane.jsx @@ -49,6 +49,7 @@ export function EditorPane({ onTableEditorToggle, scrollSyncEnabled: propScrollSyncEnabled, onScrollSyncEnabledChange, + onOpenTaskDetails, }) { const previewRef = useRef(null); const splitPaneRef = useRef(null); @@ -491,6 +492,7 @@ export function EditorPane({ showOriginalImages={showOriginalImages} inlineLinkedMarkdown={inlineLinkedMarkdown} onForceSaveDocument={onForceSaveDocument} + onOpenTaskDetails={onOpenTaskDetails} onSearchRequest={(query) => { window.dispatchEvent(new CustomEvent("open-global-search-query", { detail: { query } })); }} @@ -579,6 +581,7 @@ export function EditorPane({ showOriginalImages={showOriginalImages} inlineLinkedMarkdown={inlineLinkedMarkdown} onForceSaveDocument={onForceSaveDocument} + onOpenTaskDetails={onOpenTaskDetails} onSearchRequest={(query) => { window.dispatchEvent(new CustomEvent("open-global-search-query", { detail: { query } })); }} diff --git a/src/components/EmbeddingsPage.jsx b/src/components/EmbeddingsPage.jsx index 4ef3d69d..2824e265 100644 --- a/src/components/EmbeddingsPage.jsx +++ b/src/components/EmbeddingsPage.jsx @@ -186,7 +186,7 @@ export default function EmbeddingsPage({ onBack }) {