From ebe3e2aa8fb95d3d43be3d839eb310a0cdea8b52 Mon Sep 17 00:00:00 2001 From: skepsismusic Date: Fri, 6 Feb 2026 00:13:31 +0100 Subject: [PATCH] Initial release: Ironpad v0.1.0 - Local-first, file-based project and knowledge management system. Rust backend, Vue 3 frontend, Milkdown editor, Git integration, cross-platform builds. Built with AI using Open Method. Co-authored-by: Cursor --- .github/workflows/ci.yml | 65 + .github/workflows/release.yml | 105 + .gitignore | 45 + LICENSE | 21 + README.md | 142 + ai-context.md | 344 ++ backend/.gitignore | 1 + backend/Cargo.lock | 2342 ++++++++++ backend/Cargo.toml | 49 + backend/src/config.rs | 40 + backend/src/main.rs | 130 + backend/src/models/mod.rs | 3 + backend/src/models/note.rs | 23 + backend/src/models/project.rs | 3 + backend/src/models/task.rs | 3 + backend/src/routes/assets.rs | 265 ++ backend/src/routes/daily.rs | 319 ++ backend/src/routes/git.rs | 184 + backend/src/routes/mod.rs | 7 + backend/src/routes/notes.rs | 82 + backend/src/routes/projects.rs | 860 ++++ backend/src/routes/search.rs | 30 + backend/src/routes/tasks.rs | 835 ++++ backend/src/services/filesystem.rs | 349 ++ backend/src/services/frontmatter.rs | 191 + backend/src/services/git.rs | 655 +++ backend/src/services/locks.rs | 149 + backend/src/services/markdown.rs | 0 backend/src/services/mod.rs | 6 + backend/src/services/search.rs | 188 + backend/src/watcher.rs | 161 + backend/src/websocket.rs | 230 + data/archive/.gitkeep | 0 data/daily/.gitkeep | 0 data/notes/.gitkeep | 0 data/notes/assets/.gitkeep | 0 data/projects/.gitkeep | 0 docs/API.md | 659 +++ docs/ARCHITECTURE.md | 416 ++ docs/ai-workflow/CHECKLIST.md | 221 + docs/ai-workflow/HANDOVER.md | 128 + docs/ai-workflow/PRD.md | 1004 ++++ docs/ai-workflow/README.md | 22 + docs/ai-workflow/lessons-learned.md | 133 + docs/ai-workflow/method.md | 119 + docs/ai-workflow/tools.md | 116 + docs/screenshot.jpg | Bin 0 -> 218880 bytes frontend/.gitignore | 24 + frontend/README.md | 143 + frontend/index.html | 38 + frontend/package-lock.json | 4025 +++++++++++++++++ frontend/package.json | 37 + frontend/public/vite.svg | 1 + frontend/src/App.vue | 324 ++ frontend/src/api/client.ts | 240 + frontend/src/assets/vue.svg | 1 + frontend/src/components/ConflictBanner.vue | 65 + frontend/src/components/EditorToolbar.vue | 322 ++ frontend/src/components/GitPanel.vue | 879 ++++ frontend/src/components/GitStatus.vue | 123 + frontend/src/components/MarkdownEditor.vue | 533 +++ frontend/src/components/MarkdownPreview.vue | 204 + frontend/src/components/MilkdownEditor.vue | 108 + .../src/components/MilkdownEditorCore.vue | 442 ++ frontend/src/components/NoteList.vue | 102 + frontend/src/components/ProjectList.vue | 124 + frontend/src/components/ReadOnlyBanner.vue | 32 + frontend/src/components/SearchPanel.vue | 127 + frontend/src/components/Sidebar.vue | 338 ++ frontend/src/components/TaskPanel.vue | 156 + frontend/src/components/TopBar.vue | 316 ++ frontend/src/composables/useWebSocket.ts | 148 + frontend/src/main.ts | 12 + frontend/src/router/index.ts | 53 + frontend/src/stores/git.ts | 218 + frontend/src/stores/index.ts | 8 + frontend/src/stores/notes.ts | 131 + frontend/src/stores/projects.ts | 84 + frontend/src/stores/tasks.ts | 235 + frontend/src/stores/theme.ts | 76 + frontend/src/stores/ui.ts | 125 + frontend/src/stores/websocket.ts | 67 + frontend/src/stores/workspace.ts | 76 + frontend/src/style.css | 242 + frontend/src/types/index.ts | 175 + frontend/src/views/CalendarView.vue | 426 ++ frontend/src/views/DailyView.vue | 294 ++ frontend/src/views/DashboardView.vue | 415 ++ frontend/src/views/NotesView.vue | 358 ++ frontend/src/views/ProjectNotesView.vue | 529 +++ frontend/src/views/ProjectView.vue | 188 + frontend/src/views/ProjectsView.vue | 132 + frontend/src/views/TasksView.vue | 1624 +++++++ frontend/tsconfig.app.json | 16 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 19 + 97 files changed, 25033 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ai-context.md create mode 100644 backend/.gitignore create mode 100644 backend/Cargo.lock create mode 100644 backend/Cargo.toml create mode 100644 backend/src/config.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/models/mod.rs create mode 100644 backend/src/models/note.rs create mode 100644 backend/src/models/project.rs create mode 100644 backend/src/models/task.rs create mode 100644 backend/src/routes/assets.rs create mode 100644 backend/src/routes/daily.rs create mode 100644 backend/src/routes/git.rs create mode 100644 backend/src/routes/mod.rs create mode 100644 backend/src/routes/notes.rs create mode 100644 backend/src/routes/projects.rs create mode 100644 backend/src/routes/search.rs create mode 100644 backend/src/routes/tasks.rs create mode 100644 backend/src/services/filesystem.rs create mode 100644 backend/src/services/frontmatter.rs create mode 100644 backend/src/services/git.rs create mode 100644 backend/src/services/locks.rs create mode 100644 backend/src/services/markdown.rs create mode 100644 backend/src/services/mod.rs create mode 100644 backend/src/services/search.rs create mode 100644 backend/src/watcher.rs create mode 100644 backend/src/websocket.rs create mode 100644 data/archive/.gitkeep create mode 100644 data/daily/.gitkeep create mode 100644 data/notes/.gitkeep create mode 100644 data/notes/assets/.gitkeep create mode 100644 data/projects/.gitkeep create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/ai-workflow/CHECKLIST.md create mode 100644 docs/ai-workflow/HANDOVER.md create mode 100644 docs/ai-workflow/PRD.md create mode 100644 docs/ai-workflow/README.md create mode 100644 docs/ai-workflow/lessons-learned.md create mode 100644 docs/ai-workflow/method.md create mode 100644 docs/ai-workflow/tools.md create mode 100644 docs/screenshot.jpg create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/components/ConflictBanner.vue create mode 100644 frontend/src/components/EditorToolbar.vue create mode 100644 frontend/src/components/GitPanel.vue create mode 100644 frontend/src/components/GitStatus.vue create mode 100644 frontend/src/components/MarkdownEditor.vue create mode 100644 frontend/src/components/MarkdownPreview.vue create mode 100644 frontend/src/components/MilkdownEditor.vue create mode 100644 frontend/src/components/MilkdownEditorCore.vue create mode 100644 frontend/src/components/NoteList.vue create mode 100644 frontend/src/components/ProjectList.vue create mode 100644 frontend/src/components/ReadOnlyBanner.vue create mode 100644 frontend/src/components/SearchPanel.vue create mode 100644 frontend/src/components/Sidebar.vue create mode 100644 frontend/src/components/TaskPanel.vue create mode 100644 frontend/src/components/TopBar.vue create mode 100644 frontend/src/composables/useWebSocket.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/stores/git.ts create mode 100644 frontend/src/stores/index.ts create mode 100644 frontend/src/stores/notes.ts create mode 100644 frontend/src/stores/projects.ts create mode 100644 frontend/src/stores/tasks.ts create mode 100644 frontend/src/stores/theme.ts create mode 100644 frontend/src/stores/ui.ts create mode 100644 frontend/src/stores/websocket.ts create mode 100644 frontend/src/stores/workspace.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/views/CalendarView.vue create mode 100644 frontend/src/views/DailyView.vue create mode 100644 frontend/src/views/DashboardView.vue create mode 100644 frontend/src/views/NotesView.vue create mode 100644 frontend/src/views/ProjectNotesView.vue create mode 100644 frontend/src/views/ProjectView.vue create mode 100644 frontend/src/views/ProjectsView.vue create mode 100644 frontend/src/views/TasksView.vue create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3d37b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + name: Backend (Rust) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + backend/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('backend/Cargo.lock') }} + + - name: Check + working-directory: backend + run: cargo check + + - name: Test + working-directory: backend + run: cargo test + + - name: Format check + working-directory: backend + run: cargo fmt -- --check + + frontend: + name: Frontend (Vue) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Type check + working-directory: frontend + run: npx vue-tsc --noEmit + + - name: Build + working-directory: frontend + run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1d73e84 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + archive: tar.gz + - target: x86_64-apple-darwin + os: macos-latest + archive: tar.gz + - target: x86_64-pc-windows-msvc + os: windows-latest + archive: zip + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build frontend + working-directory: frontend + run: | + npm ci + npm run build + + - name: Copy frontend to static + shell: bash + run: | + mkdir -p backend/static + cp -r frontend/dist/* backend/static/ + + - name: Build backend (release) + working-directory: backend + run: cargo build --release --target ${{ matrix.target }} + + - name: Package (Unix) + if: matrix.archive == 'tar.gz' + shell: bash + run: | + BINARY_NAME="ironpad" + RELEASE_DIR="ironpad-${{ github.ref_name }}-${{ matrix.target }}" + mkdir -p "$RELEASE_DIR" + cp "backend/target/${{ matrix.target }}/release/$BINARY_NAME" "$RELEASE_DIR/" + cp README.md LICENSE "$RELEASE_DIR/" + tar czf "$RELEASE_DIR.tar.gz" "$RELEASE_DIR" + echo "ASSET=$RELEASE_DIR.tar.gz" >> $GITHUB_ENV + + - name: Package (Windows) + if: matrix.archive == 'zip' + shell: bash + run: | + BINARY_NAME="ironpad.exe" + RELEASE_DIR="ironpad-${{ github.ref_name }}-${{ matrix.target }}" + mkdir -p "$RELEASE_DIR" + cp "backend/target/${{ matrix.target }}/release/$BINARY_NAME" "$RELEASE_DIR/" + cp README.md LICENSE "$RELEASE_DIR/" + 7z a "$RELEASE_DIR.zip" "$RELEASE_DIR" + echo "ASSET=$RELEASE_DIR.zip" >> $GITHUB_ENV + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ironpad-${{ matrix.target }} + path: ${{ env.ASSET }} + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/**/* + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66fb657 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# === OS === +.DS_Store +Thumbs.db +Desktop.ini + +# === Editors / IDEs === +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# === Rust === +backend/target/ + +# === Node.js === +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# === Build artifacts === +backend/static/ +*.tmp + +# === User data (created at runtime) === +# The data/ directory is the user's local database. +# We include only the skeleton structure (.gitkeep files), +# not the actual notes, projects, tasks, or daily notes. +data/.git/ +data/.gitignore +data/inbox.md +data/index.md +data/archive/*.md +data/daily/*.md +data/notes/*.md +data/notes/assets/* +!data/notes/assets/.gitkeep +data/projects/*/ +!data/projects/.gitkeep + +# === Stray root lock file (frontend/package-lock.json is kept for CI) === +/package-lock.json + +# === Generated images (article assets, not source) === +/assets/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1d5a7fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ola Proeis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d6c5c8 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Ironpad + +**A local-first, file-based project & knowledge management system.** + +![Build](https://github.com/OlaProeis/ironPad/actions/workflows/release.yml/badge.svg) +![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg) +![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) +![Rust](https://img.shields.io/badge/rust-1.70%2B-orange) +![Version](https://img.shields.io/badge/version-0.1.0-green) + +Ironpad stores all your notes, projects, and tasks as plain Markdown files. No cloud services, no vendor lock-in -- your data stays on your machine in a format you can read and edit with any text editor. Every change is automatically versioned with Git. + +![Ironpad Screenshot](docs/screenshot.jpg) + +> **v0.1.0 -- Early Release.** This is the first public release. It's functional and we use it daily, but expect rough edges. Bug reports and feature requests are welcome via [Issues](https://github.com/OlaProeis/ironPad/issues). + +--- + +## Features + +- **File-based storage** -- All data stored as Markdown files with YAML frontmatter +- **Local-first** -- Works fully offline, no internet required +- **Git integration** -- Automatic version control with 60-second commit batching, full diff viewer, push/fetch +- **WYSIWYG editing** -- Milkdown editor with real-time markdown rendering and formatting toolbar +- **Project management** -- Organize tasks and notes by project with due dates, tags, subtasks, and recurrence +- **Calendar view** -- Month grid showing tasks by due date with color-coded urgency +- **Dashboard** -- Cross-project overview with active task summaries +- **Daily notes** -- Quick capture with templates for daily journaling +- **Real-time sync** -- WebSocket-based live updates; edit in VS Code, see changes in the browser instantly +- **External editing** -- Full support for VS Code, Obsidian, Vim, or any text editor +- **Search** -- ripgrep-powered full-text search across all files (Ctrl+K) +- **Dark theme** -- Beautiful dark UI by default with light mode toggle +- **Tiny footprint** -- 5 MB binary, ~20 MB RAM, sub-second startup + +## Quick Start + +### Option 1: Download Release (Recommended) + +1. Download the latest release for your platform from [Releases](https://github.com/OlaProeis/ironPad/releases) +2. Extract and run the executable +3. Your browser opens automatically -- start using Ironpad + +Data is stored in a `data/` folder next to the executable. To use a custom location, set the `IRONPAD_DATA_DIR` environment variable. + +### Option 2: Build From Source + +**Prerequisites:** [Rust](https://rustup.rs/) (1.70+), [Node.js](https://nodejs.org/) (18+), [Git](https://git-scm.com/) + +```bash +# Clone the repository +git clone https://github.com/OlaProeis/ironPad.git +cd ironPad + +# Start the backend +cd backend +cargo run + +# In a new terminal, start the frontend +cd frontend +npm install +npm run dev +``` + +Open http://localhost:5173 in your browser. + +## Tech Stack + +| Component | Technology | +|-----------|------------| +| Backend | Rust, Axum 0.8, Tokio | +| Frontend | Vue 3, Vite, TypeScript | +| Editor | Milkdown (ProseMirror-based) | +| State | Pinia | +| Routing | Vue Router | +| Data | Markdown + YAML frontmatter | +| Version Control | Git (via git2) | +| Search | ripgrep | + +## Roadmap + +Ironpad is under active development. Here's what's planned: + +- [ ] UI polish and animations +- [ ] Tag extraction and filtering across projects +- [ ] Backlinks between notes +- [ ] Graph view of note connections +- [ ] Export to PDF / HTML +- [ ] Custom themes +- [ ] Global hotkey (Ctrl+Shift+Space) +- [ ] System tray mode +- [ ] Kanban board view for tasks + +See [CHECKLIST.md](docs/ai-workflow/CHECKLIST.md) for detailed implementation status. + +## Built With AI + +This entire application was built using AI-assisted development -- an approach we call **Open Method**. We share not just the code, but the complete process: the PRD, task breakdowns, handover documents, and workflow artifacts. + +Read about the method: +- [The AI Development Workflow I Actually Use](https://dev.to/olaproeis/the-ai-development-workflow-i-actually-use-549i) -- The original workflow article +- [docs/ai-workflow/](docs/ai-workflow/) -- Documentation of the AI-assisted development process used to build Ironpad + +**Tools used:** Cursor IDE, Claude Opus 4.5/4.6, Context7 MCP + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var | +| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection | +| Auto-commit | Every 60 seconds | Git commits when changes exist | +| Auto-save | 1 second debounce | Frontend saves after typing stops | + +## Documentation + +| Document | Description | +|----------|-------------| +| [docs/API.md](docs/API.md) | Complete REST API reference | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design and technical details | +| [docs/ai-workflow/](docs/ai-workflow/) | AI development workflow and methodology | + +## Contributing + +This is an early release and contributions are welcome! + +1. Check [Issues](https://github.com/OlaProeis/ironPad/issues) for open bugs and feature requests +2. Create a branch for your feature/fix +3. Follow the code style (`cargo fmt` for Rust) +4. Test your changes thoroughly +5. Submit a pull request + +## License + +[MIT License](LICENSE) + +## Acknowledgments + +- [Milkdown](https://milkdown.dev/) -- WYSIWYG Markdown editor +- [Axum](https://github.com/tokio-rs/axum) -- Rust web framework +- [Vue.js](https://vuejs.org/) -- Frontend framework +- [Pinia](https://pinia.vuejs.org/) -- State management +- [Anthropic Claude](https://www.anthropic.com/) -- AI-assisted development diff --git a/ai-context.md b/ai-context.md new file mode 100644 index 0000000..21418ac --- /dev/null +++ b/ai-context.md @@ -0,0 +1,344 @@ +# AI Context — Ironpad + +Paste this into every new AI chat for project context. + +--- + +## What It Is + +**Ironpad** is a local-first, file-based personal project & knowledge management system. + +- **Backend**: Rust (Axum 0.8, Tokio) +- **Frontend**: Vue 3 (Vite) +- **Data**: Plain Markdown files with YAML frontmatter +- **Versioning**: Local Git repository +- **UI**: System browser (no Electron) + +--- + +## Core Principles + +1. **Files are the database** — filesystem is source of truth +2. **Local-first** — works fully offline +3. **External editing supported** — VS Code, Obsidian, Vim all work +4. **Backend owns metadata** — `id`, `created`, `updated` are auto-managed +5. **Low ceremony** — minimal config, no manual metadata editing + +--- + +## Current Architecture + +``` +ironpad/ +├── backend/ # Rust Axum server +│ └── src/ +│ ├── main.rs # Server bootstrap, WebSocket, routes +│ ├── routes/ # API endpoints +│ ├── services/ # Business logic (filesystem, git, search) +│ ├── models/ # Data structures +│ ├── websocket.rs # Real-time sync +│ └── watcher.rs # File system watching +├── frontend/ # Vue 3 SPA +│ └── src/ +│ ├── App.vue # Root component with router-view +│ ├── main.ts # Entry point (Pinia + Vue Router) +│ ├── router/ # Vue Router config +│ ├── stores/ # Pinia stores (notes, projects, tasks, ui, websocket, git) +│ ├── views/ # Route views (DashboardView, ProjectView, TasksView, CalendarView, DailyView) +│ ├── components/ # Reusable components (Sidebar, MarkdownEditor, etc.) +│ ├── composables/ # Vue composables (useWebSocket) +│ ├── api/ # API client (client.ts) +│ └── types/ # TypeScript types (index.ts) +└── data/ # User data (separate git repo) + ├── notes/ # Standalone notes + ├── projects/ # Project folders + │ └── {project}/ + │ ├── index.md # Project overview + │ ├── notes/ # Project-specific notes + │ └── tasks/ # Individual task files (task-YYYYMMDD-HHMMSS.md) + ├── daily/ # Daily notes (YYYY-MM-DD.md) + ├── archive/ # Archived items + ├── index.md # Landing page + └── inbox.md # Quick capture +``` + +--- + +## Implemented Features + +### Backend +- API-only server (no frontend serving, no browser auto-open) +- Dynamic port (3000-3010) +- Notes CRUD with atomic writes +- Frontmatter auto-management +- WebSocket server for real-time sync + file locking +- File watcher (filters own saves) +- Search (ripgrep with fallback) +- Git status + auto-commit (60s batching) + push + conflict detection +- **Full Git panel** with commit history, diff viewer, custom commit messages +- Git remote info (ahead/behind tracking), fetch support +- Projects API with notes management +- **File-based Tasks API** — each task is a markdown file with frontmatter + - Fields: id, title, completed, section, priority, due_date, is_active, tags, parent_id, recurrence, recurrence_interval + - Rich text descriptions with markdown support + - Sorted by created date (stable ordering) + - **Subtasks** — tasks with `parent_id` link to a parent task + - **Tags** — YAML sequence in frontmatter, per-task labels for filtering + - **Recurring tasks** — when completing a recurring task, auto-creates next instance with advanced due date +- Daily notes API (`/api/daily`, `/api/daily/today`, `/api/daily/:date`) +- Assets API (upload + serve) + +### Frontend +- Vue Router navigation +- Pinia state management +- **Milkdown WYSIWYG editor** — ProseMirror-based, renders markdown as you type + - CommonMark + GFM support (tables, strikethrough, task lists) + - **Toolbar** with formatting buttons (bold, italic, headings, links, images, code, lists, quotes) + - **Image upload** — click image button, select file, auto-uploads and inserts markdown + - History (undo/redo), clipboard, indentation plugins +- **Dark theme by default** with toggle button (persists to localStorage) +- Sidebar with Notes/Projects/Daily/Calendar sections + task counts +- Search panel (Ctrl+K) +- **Dashboard view** (home page) — all projects as cards with active task summaries + - Click project to navigate, click task to open detail + - Shows active/backlog/overdue counts per project +- **Split-panel Task view** with: + - Task list (Active/Backlog/Completed sections) + - Task detail editor with markdown descriptions + - Preview toggle for rendered markdown + - Active/Backlog toggle button + - **Due date picker** — inline date input to set/clear due dates + - Due date display with color-coded urgency + - **Tag system** — add/remove tags with autocomplete from project tags + - **Tag filter bar** — click tags to filter task list + - **Subtasks** — expandable subtasks under parent tasks, add subtask inline + - **Recurrence picker** — set daily/weekly/monthly/yearly recurrence + - Inline title editing (double-click) +- **Calendar view** — month grid showing tasks by due date + - Tasks with due dates plotted on calendar cells + - Daily notes shown as blue dots + - Color-coded urgency (overdue, today, soon) + - Month navigation + Today button + - Click task to navigate to detail, click date to open daily note +- **Split-panel Notes view** (per project) +- **Git panel** — slide-out panel with: + - Commit history with expandable diffs + - Working directory changes with line-by-line diff + - Custom commit messages (Ctrl+Enter to commit) + - Push/Fetch buttons with ahead/behind indicators + - File status icons (added/modified/deleted/renamed) +- Git status indicator in sidebar (click to open panel) +- WebSocket real-time updates +- File lock banners (read-only mode when locked) +- Conflict warning banner +- Fullscreen layout (uses all available space) + +### API Endpoints +``` +GET/POST /api/notes +GET/PUT/DEL /api/notes/:id +GET/POST /api/projects +GET/PUT /api/projects/:id +GET/PUT /api/projects/:id/content + +# Project Notes (file-based) +GET/POST /api/projects/:id/notes +GET/PUT/DEL /api/projects/:id/notes/:note_id + +# Project Tasks (file-based, each task is a .md file) +GET/POST /api/projects/:id/tasks +GET/PUT/DEL /api/projects/:id/tasks/:task_id +PUT /api/projects/:id/tasks/:task_id/toggle +PUT /api/projects/:id/tasks/:task_id/meta + +GET /api/tasks # All tasks across projects +GET /api/daily +GET /api/daily/today +GET/POST /api/daily/:date +POST /api/assets/upload +GET /api/assets/:project/:file +GET /api/search?q= +GET /api/git/status +POST /api/git/commit +POST /api/git/push +GET /api/git/conflicts +GET /api/git/log # Commit history (limit param) +GET /api/git/diff # Working directory diff +GET /api/git/diff/:commit_id # Diff for specific commit +GET /api/git/remote # Remote repository info +POST /api/git/fetch # Fetch from remote +WS /ws +``` + +--- + +## Implemented in Phase 3 + +- CodeMirror 6 editor with markdown syntax highlighting +- Markdown preview with split view +- Vue Router for navigation (`/`, `/projects/:id`, `/projects/:id/tasks`, `/calendar`, `/daily`) +- Pinia state management (notes, projects, tasks, ui, websocket, git stores) +- Project-specific task view with toggle and add functionality +- File locking via WebSocket (Task View vs Editor) +- Daily notes (`data/daily/`) with templates +- Assets upload API (`/api/assets/upload`, `/api/assets/:project/:file`) +- Git push and conflict detection (`/api/git/push`, `/api/git/conflicts`) + +--- + +## Implemented in Phase 4 + +- **File-based tasks** — each task is a separate markdown file with YAML frontmatter + - Supports rich text descriptions with images + - New fields: `due_date`, `is_active` + - Task files stored in `projects/{id}/tasks/task-YYYYMMDD-HHMMSS.md` +- **Split-panel task view** — list on left, detail editor on right +- **Markdown preview toggle** — side-by-side raw/rendered view +- **Active/Backlog toggle** — button to move tasks between states +- **Project notes** — separate notes folder per project with split-panel view +- **Stable list sorting** — sorted by created date (not updated) +- **Backend API-only mode** — no frontend serving, no browser auto-open +- **Fullscreen layout** — uses all available browser space + +--- + +## Known Issues / Technical Debt + +1. **Project index note ID format**: Project notes use `{slug}-index` as their ID (e.g., `ferrite-index`). Projects created before this fix have incorrect IDs in frontmatter. + +2. **Axum nested route limitation**: Path parameters from parent routes are NOT automatically available in nested route handlers. Project task routes use explicit routes instead of `.nest()`. + +3. **Some warnings remain**: Unused methods in `locks.rs` and `git.rs` (reserved for future use). + +--- + +## Implemented in Phase 5 + +- **Dashboard view** — cross-project home page with task summaries per project +- **Tags system** — per-task tags stored in frontmatter, filter bar in task list, autocomplete +- **Subtasks** — tasks with `parent_id`, grouped under parents in list, inline creation +- **Recurring tasks** — daily/weekly/monthly/yearly with auto-creation on completion +- **Calendar view** — month grid with tasks by due date + daily note indicators +- **Due date picker** — inline date input in task detail panel +- **Clickable app title** — "Ironpad" navigates to dashboard + +--- + +## Not Yet Implemented (Phase 6+) + +- UI polish and animations +- Responsive sidebar +- Global hotkey (Ctrl+Shift+Space) +- System tray mode +- Backlinks between notes +- Graph view +- Export (PDF / HTML) +- Custom themes +- Tantivy search (if >5000 notes) +- Production packaging (Tauri or similar) +- Task dependencies (blocked by) +- Time estimates on tasks +- Calendar drag-and-drop rescheduling +- Week/day calendar views + +--- + +## Key Technical Decisions + +| Decision | Choice | +|----------|--------| +| Data path | `../data` relative to backend | +| Port | Dynamic 3000-3010 | +| Auto-save | 1s debounce in frontend | +| Git commits | 60s batch + manual button | +| File watcher | notify crate, 500ms debounce | +| Search | ripgrep CLI, fallback to manual | +| Frontmatter | serde_yaml, auto-generated IDs | +| Editor | Milkdown (WYSIWYG ProseMirror-based) | +| Editor Legacy | CodeMirror 6 (MarkdownEditor.vue, kept for reference) | +| State management | Pinia stores | +| Routing | Vue Router (history mode) | +| File locking | WebSocket-based, per-client locks | +| Project note ID | `{slug}-index` format | +| Task storage | Individual .md files in `tasks/` folder | +| List sorting | By created date (stable, not affected by edits) | +| Backend mode | API-only (no frontend serving) | +| Theme | Dark by default, toggle to light, persists to localStorage | +| Tags | YAML sequence in frontmatter, project-scoped filtering | +| Subtasks | Separate task files with `parent_id` field linking to parent | +| Recurring tasks | On completion, backend auto-creates next instance with advanced due date | +| Calendar | Pure frontend month grid, tasks filtered by `due_date` presence | +| Dashboard | Home route `/`, loads all projects + all tasks for cross-project summary | + +--- + +## Critical: Milkdown Editor Lifecycle + +The Milkdown editor requires careful handling when switching between notes/tasks: + +**Components:** +- `MilkdownEditor.vue` — Wrapper with `:key` prop for recreation +- `MilkdownEditorCore.vue` — Actual editor using `useEditor` hook from `@milkdown/vue` + +**Pattern for switching content:** +```javascript +// Views use a separate editorKey ref (not the noteId/taskId directly) +// Content MUST be set BEFORE updating editorKey + +// CORRECT order: +editorContent.value = loadedContent // Set content first +editorKey.value = noteId // Then trigger editor recreation + +// WRONG order (causes stale content): +editorKey.value = noteId // Editor recreates with empty/stale defaultValue +editorContent.value = loadedContent // Too late - editor already initialized +``` + +**Why:** The editor uses `defaultValue` from props at creation time. If the key changes before content is set, the editor initializes with wrong content and `replaceAll()` updates may fail during the async initialization window. + +**State in MilkdownEditorCore must be refs, not module-level variables** — ensures clean state on component recreation. + +--- + +## Development Commands + +```bash +# Backend (from backend/) +cargo run # API server on :3000 (no GUI) + +# Frontend (from frontend/) +npm run dev # Dev server on :5173 (connects to backend on :3000) +npm run build # Build to dist/ + +# Development: Run both backend and frontend separately +# Backend is API-only, does not serve frontend +``` + +--- + +## Documentation + +For detailed information, see: + +| Document | Description | +|----------|-------------| +| `/README.md` | Project overview, quick start, installation | +| `/frontend/README.md` | Frontend architecture, component structure, Milkdown editor patterns | +| `/docs/ARCHITECTURE.md` | System design, service layer, data models, security considerations | +| `/docs/API.md` | Complete REST API reference with examples | +| `/HANDOVER.md` | Session handover notes, recent fixes, context for continuing work | +| `/CHECKLIST.md` | Current implementation status and progress | +| `/PRD.md` | Full product requirements document | + +--- + +## Rules for AI + +- Prefer incremental, verifiable changes +- File system is source of truth +- No databases, no cloud services +- Windows + PowerShell environment +- Rust 2021 edition +- Check CHECKLIST.md for current status +- Check PRD.md for full requirements +- Check HANDOVER.md for recent session context \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +/target diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..7297547 --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,2342 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "git2" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +dependencies = [ + "bitflags 2.10.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308ae749734e28d749a86f33212c7b756748568ce332f970ac1d9cd8531f32e6" +dependencies = [ + "grep-cli", + "grep-matcher", + "grep-printer", + "grep-regex", + "grep-searcher", +] + +[[package]] +name = "grep-cli" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631" +dependencies = [ + "bstr", + "globset", + "libc", + "log", + "termcolor", + "winapi-util", +] + +[[package]] +name = "grep-matcher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-printer" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c112110ae4a891aa4d83ab82ecf734b307497d066f437686175e83fbd4e013fe" +dependencies = [ + "bstr", + "grep-matcher", + "grep-searcher", + "log", + "serde", + "serde_json", + "termcolor", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "ironpad" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "futures-util", + "git2", + "grep", + "lazy_static", + "markdown", + "notify", + "notify-debouncer-full", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", + "walkdir", + "webbrowser", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libgit2-sys" +version = "0.17.0+1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.10.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb7fd166739789c9ff169e654dc1501373db9d80a4c3f972817c8a4d7cf8f34e" +dependencies = [ + "crossbeam-channel", + "file-id", + "log", + "notify", + "parking_lot", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio 1.1.1", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +dependencies = [ + "core-foundation", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..54d6700 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "ironpad" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Web framework +axum = { version = "0.8", features = ["ws", "multipart"] } +tokio = { version = "1", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "normalize-path", "fs"] } + +# Browser opening (production mode) +webbrowser = "1.0" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" + +# Markdown parsing (CommonMark) +markdown = "1.0.0-alpha.22" + +# Git operations +git2 = "0.19" + + +# File system watching +notify = "6.1" +notify-debouncer-full = "0.3" + +# Search (ripgrep internals) +grep = "0.3" +walkdir = "2.4" + +# Date/time +chrono = { version = "0.4", features = ["serde"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# WebSocket support +futures-util = "0.3" +uuid = { version = "1.0", features = ["v4"] } + +# Utilities +lazy_static = "1.4" +tokio-util = { version = "0.7", features = ["io"] } \ No newline at end of file diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..52760e8 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,40 @@ +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// Resolved data directory path. +/// Priority: IRONPAD_DATA_DIR env var > auto-detect (production vs development). +static DATA_DIR: OnceLock = OnceLock::new(); + +/// Initialize the data directory path. Call once at startup. +/// +/// Resolution order: +/// 1. `IRONPAD_DATA_DIR` environment variable (if set) +/// 2. `./data` if `static/index.html` exists (production mode) +/// 3. `../data` (development mode, binary runs from backend/) +pub fn init_data_dir() { + let path = if let Ok(custom) = std::env::var("IRONPAD_DATA_DIR") { + tracing::info!("Using custom data directory from IRONPAD_DATA_DIR"); + PathBuf::from(custom) + } else if Path::new("static/index.html").exists() { + // Production mode: data/ is next to the binary + PathBuf::from("data") + } else { + // Development mode: binary runs from backend/, data/ is one level up + PathBuf::from("../data") + }; + + // Create the data directory if it doesn't exist + if !path.exists() { + if let Err(e) = std::fs::create_dir_all(&path) { + tracing::error!("Failed to create data directory {}: {}", path.display(), e); + } + } + + tracing::info!("Data directory: {}", path.display()); + DATA_DIR.set(path).expect("Data directory already initialized"); +} + +/// Get the resolved data directory path. +pub fn data_dir() -> &'static Path { + DATA_DIR.get().expect("Data directory not initialized. Call config::init_data_dir() first.") +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..90a6019 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,130 @@ +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; + +use axum::{routing::get, Router}; +use tokio::net::TcpListener; +use tower_http::cors::CorsLayer; +use tower_http::services::ServeDir; +use tracing::{info, warn}; + +pub mod config; +mod models; +mod routes; +mod services; +mod watcher; +mod websocket; + +/// Find an available port and return the bound listener. +/// Avoids TOCTOU race by keeping the listener alive. +async fn find_available_port() -> (TcpListener, u16) { + for port in 3000..=3010 { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + if let Ok(listener) = TcpListener::bind(addr).await { + return (listener, port); + } + } + panic!("No available ports in range 3000–3010"); +} + +#[tokio::main] +async fn main() { + // Logging + tracing_subscriber::fmt().init(); + + // Resolve data directory (production vs development mode) + config::init_data_dir(); + + // Find port and bind (listener kept alive to avoid race condition) + let (listener, port) = find_available_port().await; + + // WebSocket state (shared across handlers) + let ws_state = Arc::new(websocket::WsState::new()); + + // Start file watcher + let ws_state_clone = ws_state.clone(); + if let Err(e) = watcher::start_watcher(ws_state_clone).await { + warn!("File watcher failed to start: {}", e); + } + + // Initialize git repo if needed + if let Err(e) = services::git::init_repo() { + warn!("Git init skipped: {}", e); + } + + // Start auto-commit background task (tries to commit every 60s) + services::git::start_auto_commit(); + + // CORS layer (permissive for local-only app) + let cors = CorsLayer::permissive(); + + // API router + let api_router = Router::new() + // Notes CRUD + .route( + "/notes", + get(routes::notes::list_notes).post(routes::notes::create_note), + ) + .nest("/notes", routes::notes::router()) + // Tasks + .nest("/tasks", routes::tasks::router()) + // Search + .nest("/search", routes::search::router()) + // Git + .nest("/git", routes::git::router()) + // Projects + .nest("/projects", routes::projects::router()) + // Daily notes + .nest("/daily", routes::daily::router()) + // Assets + .nest("/assets", routes::assets::router()); + + // App router with WebSocket state + let mut app = Router::new() + .route("/health", get(|| async { "ok" })) + .route( + "/ws", + get({ + let ws = ws_state.clone(); + move |upgrade: axum::extract::WebSocketUpgrade| { + websocket::ws_handler(upgrade, axum::extract::State(ws)) + } + }), + ) + .nest("/api", api_router) + .layer(cors); + + // Check for embedded frontend (production mode) + let static_dir = Path::new("static"); + let has_frontend = static_dir.join("index.html").exists(); + + if has_frontend { + // Production mode: serve frontend from static/ and use SPA fallback + info!("Production mode: serving frontend from static/"); + let serve_dir = ServeDir::new("static") + .fallback(tower_http::services::ServeFile::new("static/index.html")); + app = app.fallback_service(serve_dir); + } else { + // Development mode: API-only + app = app.fallback(|| async { + "Ironpad API server running. Use 'npm run dev' in frontend/ for the GUI." + }); + } + + // Start server + info!("🚀 Ironpad running on http://localhost:{port}"); + + // Auto-open browser in production mode + if has_frontend { + let url = format!("http://localhost:{}", port); + tokio::spawn(async move { + // Small delay to ensure server is ready + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + if let Err(e) = webbrowser::open(&url) { + tracing::warn!("Failed to open browser: {}. Open http://localhost:{} manually.", e, port); + } + }); + } + + axum::serve(listener, app).await.expect("Server failed"); +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs new file mode 100644 index 0000000..4cdf045 --- /dev/null +++ b/backend/src/models/mod.rs @@ -0,0 +1,3 @@ +pub mod note; +pub mod project; +pub mod task; \ No newline at end of file diff --git a/backend/src/models/note.rs b/backend/src/models/note.rs new file mode 100644 index 0000000..0a492cf --- /dev/null +++ b/backend/src/models/note.rs @@ -0,0 +1,23 @@ +use serde::Serialize; + +/// Lightweight note representation for list views. +/// Read-only, derived from filesystem + frontmatter. +#[derive(Debug, Serialize)] +pub struct NoteSummary { + pub id: String, + pub title: String, + pub path: String, + pub note_type: String, + pub updated: Option, +} + +/// Full note payload for editor view. +/// Returned by GET /api/notes/:id +#[derive(Debug, Serialize)] +pub struct Note { + pub id: String, + pub path: String, + pub note_type: String, + pub frontmatter: serde_yaml::Mapping, + pub content: String, +} diff --git a/backend/src/models/project.rs b/backend/src/models/project.rs new file mode 100644 index 0000000..22883af --- /dev/null +++ b/backend/src/models/project.rs @@ -0,0 +1,3 @@ +// Project structs are defined inline in routes/projects.rs +// because they are tightly coupled to the API response shape. +// This module is kept as a placeholder for future shared types. diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs new file mode 100644 index 0000000..ff2f54a --- /dev/null +++ b/backend/src/models/task.rs @@ -0,0 +1,3 @@ +// Task structs are defined inline in routes/tasks.rs +// because they are tightly coupled to the API response shape. +// This module is kept as a placeholder for future shared types. diff --git a/backend/src/routes/assets.rs b/backend/src/routes/assets.rs new file mode 100644 index 0000000..8040524 --- /dev/null +++ b/backend/src/routes/assets.rs @@ -0,0 +1,265 @@ +use axum::{ + body::Body, + extract::{Multipart, Path, Query}, + http::{header, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::Path as StdPath; +use tokio_util::io::ReaderStream; + +use crate::config; +const MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB + +#[derive(Debug, Deserialize)] +pub struct UploadQuery { + pub project: Option, +} + +#[derive(Debug, Serialize)] +pub struct UploadResponse { + pub url: String, + pub filename: String, + pub size: usize, +} + +pub fn router() -> Router { + Router::new() + .route("/upload", post(upload_asset)) + .route("/{project}/{filename}", get(get_asset)) +} + +async fn upload_asset( + Query(query): Query, + mut multipart: Multipart, +) -> impl IntoResponse { + // Determine target directory + let assets_dir = if let Some(project_id) = &query.project { + config::data_dir() + .join("projects") + .join(project_id) + .join("assets") + } else { + config::data_dir().join("notes").join("assets") + }; + + // Create assets directory if it doesn't exist + if !assets_dir.exists() { + if let Err(e) = fs::create_dir_all(&assets_dir) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create assets directory: {}", e), + ) + .into_response(); + } + } + + // Process uploaded file + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("file").to_string(); + if name != "file" { + continue; + } + + let original_filename = field + .file_name() + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("upload_{}", chrono::Utc::now().timestamp())); + + // Validate file type (images only for now) + let content_type = field + .content_type() + .map(|s| s.to_string()) + .unwrap_or_default(); + + if !is_allowed_content_type(&content_type) { + return ( + StatusCode::BAD_REQUEST, + format!("Unsupported file type: {}. Only images are allowed.", content_type), + ) + .into_response(); + } + + // Read file data + let data = match field.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + format!("Failed to read file data: {}", e), + ) + .into_response(); + } + }; + + // Check file size + if data.len() > MAX_FILE_SIZE { + return ( + StatusCode::BAD_REQUEST, + format!("File too large. Maximum size is {} MB.", MAX_FILE_SIZE / 1024 / 1024), + ) + .into_response(); + } + + // Generate unique filename if needed + let filename = generate_unique_filename(&assets_dir, &original_filename); + let file_path = assets_dir.join(&filename); + + // Write file + let mut file = match fs::File::create(&file_path) { + Ok(f) => f, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create file: {}", e), + ) + .into_response(); + } + }; + + if let Err(e) = file.write_all(&data) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to write file: {}", e), + ) + .into_response(); + } + + // Build response URL + let project_part = query.project.as_deref().unwrap_or("notes"); + let url = format!("/api/assets/{}/{}", project_part, filename); + + return ( + StatusCode::CREATED, + Json(UploadResponse { + url, + filename, + size: data.len(), + }), + ) + .into_response(); + } + + (StatusCode::BAD_REQUEST, "No file provided").into_response() +} + +/// Validate that a path component doesn't contain directory traversal +fn validate_path_component(component: &str) -> Result<(), String> { + if component.contains("..") || component.contains('/') || component.contains('\\') || component.is_empty() { + return Err("Invalid path component".to_string()); + } + Ok(()) +} + +async fn get_asset(Path((project, filename)): Path<(String, String)>) -> impl IntoResponse { + // Validate path components to prevent directory traversal + if validate_path_component(&project).is_err() || validate_path_component(&filename).is_err() { + return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); + } + + // Determine file path + let file_path = if project == "notes" { + config::data_dir() + .join("notes") + .join("assets") + .join(&filename) + } else { + config::data_dir() + .join("projects") + .join(&project) + .join("assets") + .join(&filename) + }; + + // Check if file exists + if !file_path.exists() { + return (StatusCode::NOT_FOUND, "Asset not found").into_response(); + } + + // Read file + let file = match tokio::fs::File::open(&file_path).await { + Ok(f) => f, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to open file: {}", e), + ) + .into_response(); + } + }; + + // Determine content type + let content_type = get_content_type(&filename); + + // Stream file response + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + ( + StatusCode::OK, + [(header::CONTENT_TYPE, content_type)], + body, + ) + .into_response() +} + +fn is_allowed_content_type(content_type: &str) -> bool { + matches!( + content_type, + "image/jpeg" + | "image/png" + | "image/gif" + | "image/webp" + | "image/svg+xml" + | "application/pdf" + ) +} + +fn get_content_type(filename: &str) -> &'static str { + let ext = filename + .rsplit('.') + .next() + .unwrap_or("") + .to_lowercase(); + + match ext.as_str() { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "pdf" => "application/pdf", + _ => "application/octet-stream", + } +} + +fn generate_unique_filename(dir: &StdPath, original: &str) -> String { + // Extract name and extension + let (name, ext) = if let Some(dot_idx) = original.rfind('.') { + (&original[..dot_idx], &original[dot_idx..]) + } else { + (original, "") + }; + + // Sanitize filename + let sanitized_name: String = name + .chars() + .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect(); + + let base_filename = format!("{}{}", sanitized_name, ext); + let target_path = dir.join(&base_filename); + + // If file doesn't exist, use original name + if !target_path.exists() { + return base_filename; + } + + // Otherwise, add timestamp + let timestamp = chrono::Utc::now().timestamp_millis(); + format!("{}_{}{}", sanitized_name, timestamp, ext) +} diff --git a/backend/src/routes/daily.rs b/backend/src/routes/daily.rs new file mode 100644 index 0000000..c757d38 --- /dev/null +++ b/backend/src/routes/daily.rs @@ -0,0 +1,319 @@ +use axum::{ + body::Bytes, + extract::Path, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use chrono::{NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; + +use crate::services::filesystem; +use crate::config; +use crate::services::frontmatter; + +#[derive(Debug, Serialize)] +pub struct DailyNote { + pub id: String, + pub date: String, + pub path: String, + pub content: String, + pub frontmatter: serde_yaml::Mapping, +} + +#[derive(Debug, Serialize)] +pub struct DailyNoteSummary { + pub id: String, + pub date: String, + pub path: String, + pub title: String, +} + +pub fn router() -> Router { + Router::new() + .route("/", get(list_daily_notes)) + .route("/today", get(get_or_create_today)) + .route("/{date}", get(get_daily_note).post(create_daily_note).put(update_daily_note)) +} + +/// List all daily notes +async fn list_daily_notes() -> impl IntoResponse { + match list_daily_notes_impl() { + Ok(notes) => Json(notes).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to list daily notes: {}", err), + ) + .into_response(), + } +} + +fn list_daily_notes_impl() -> Result, String> { + let daily_dir = config::data_dir().join("daily"); + + // Create directory if it doesn't exist + if !daily_dir.exists() { + fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?; + return Ok(Vec::new()); + } + + let mut notes = Vec::new(); + + for entry in fs::read_dir(&daily_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + + // Validate date format + if NaiveDate::parse_from_str(filename, "%Y-%m-%d").is_err() { + continue; + } + + let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; + let (fm, _, _) = frontmatter::parse_frontmatter(&content); + + let title = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| filename.to_string()); + + notes.push(DailyNoteSummary { + id: format!("daily-{}", filename), + date: filename.to_string(), + path: format!("daily/{}.md", filename), + title, + }); + } + + // Sort by date descending + notes.sort_by(|a, b| b.date.cmp(&a.date)); + + Ok(notes) +} + +/// Get or create today's daily note +async fn get_or_create_today() -> impl IntoResponse { + let today = Utc::now().format("%Y-%m-%d").to_string(); + + match get_daily_note_impl(&today) { + Ok(note) => Json(note).into_response(), + Err(_) => { + // Note doesn't exist, create it with default template + match create_daily_note_impl(&today, None) { + Ok(note) => (StatusCode::CREATED, Json(note)).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create today's note: {}", err), + ) + .into_response(), + } + } + } +} + +/// Get a daily note by date +async fn get_daily_note(Path(date): Path) -> impl IntoResponse { + // Validate date format + if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() { + return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response(); + } + + match get_daily_note_impl(&date) { + Ok(note) => Json(note).into_response(), + Err(err) if err.contains("not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get daily note: {}", err), + ) + .into_response(), + } +} + +fn get_daily_note_impl(date: &str) -> Result { + let daily_dir = config::data_dir().join("daily"); + let note_path = daily_dir.join(format!("{}.md", date)); + + if !note_path.exists() { + return Err(format!("Daily note not found: {}", date)); + } + + let content = fs::read_to_string(¬e_path).map_err(|e| e.to_string())?; + let (fm, body, _) = frontmatter::parse_frontmatter(&content); + + Ok(DailyNote { + id: format!("daily-{}", date), + date: date.to_string(), + path: format!("daily/{}.md", date), + content: body, + frontmatter: fm, + }) +} + +#[derive(Debug, Deserialize)] +pub struct CreateDailyNoteRequest { + pub content: Option, +} + +/// Create a daily note (optionally with content) +async fn create_daily_note( + Path(date): Path, + body: Option>, +) -> impl IntoResponse { + // Validate date format + if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() { + return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response(); + } + + let content = body.and_then(|b| b.content.clone()); + + match create_daily_note_impl(&date, content.as_deref()) { + Ok(note) => (StatusCode::CREATED, Json(note)).into_response(), + Err(err) if err.contains("already exists") => { + (StatusCode::CONFLICT, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create daily note: {}", err), + ) + .into_response(), + } +} + +fn create_daily_note_impl(date: &str, initial_content: Option<&str>) -> Result { + let daily_dir = config::data_dir().join("daily"); + + // Create directory if it doesn't exist + if !daily_dir.exists() { + fs::create_dir_all(&daily_dir).map_err(|e| e.to_string())?; + } + + let note_path = daily_dir.join(format!("{}.md", date)); + + if note_path.exists() { + return Err(format!("Daily note already exists: {}", date)); + } + + let now = Utc::now().to_rfc3339(); + + // Parse date for display + let parsed_date = NaiveDate::parse_from_str(date, "%Y-%m-%d") + .map_err(|e| e.to_string())?; + let display_date = parsed_date.format("%A, %B %d, %Y").to_string(); + + // Create frontmatter + let mut fm = serde_yaml::Mapping::new(); + fm.insert( + serde_yaml::Value::from("id"), + serde_yaml::Value::from(format!("daily-{}", date)), + ); + fm.insert( + serde_yaml::Value::from("type"), + serde_yaml::Value::from("daily"), + ); + fm.insert( + serde_yaml::Value::from("title"), + serde_yaml::Value::from(display_date.clone()), + ); + fm.insert( + serde_yaml::Value::from("date"), + serde_yaml::Value::from(date), + ); + fm.insert( + serde_yaml::Value::from("created"), + serde_yaml::Value::from(now.clone()), + ); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now), + ); + + // Use provided content or default template + let body = initial_content + .map(|c| c.to_string()) + .unwrap_or_else(|| { + format!( + "# {}\n\n## Today's Focus\n\n- \n\n## Notes\n\n\n\n## Tasks\n\n- [ ] \n", + display_date + ) + }); + + let content = frontmatter::serialize_frontmatter(&fm, &body)?; + + filesystem::atomic_write(¬e_path, content.as_bytes())?; + + Ok(DailyNote { + id: format!("daily-{}", date), + date: date.to_string(), + path: format!("daily/{}.md", date), + content: body, + frontmatter: fm, + }) +} + +/// Update a daily note's content +async fn update_daily_note( + Path(date): Path, + body: Bytes, +) -> impl IntoResponse { + // Validate date format + if NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() { + return (StatusCode::BAD_REQUEST, "Invalid date format. Use YYYY-MM-DD").into_response(); + } + + let content = String::from_utf8_lossy(&body).to_string(); + + match update_daily_note_impl(&date, &content) { + Ok(note) => Json(note).into_response(), + Err(err) if err.contains("not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update daily note: {}", err), + ) + .into_response(), + } +} + +fn update_daily_note_impl(date: &str, new_content: &str) -> Result { + let daily_dir = config::data_dir().join("daily"); + let note_path = daily_dir.join(format!("{}.md", date)); + + if !note_path.exists() { + return Err(format!("Daily note not found: {}", date)); + } + + // Read existing file to preserve frontmatter + let existing_content = fs::read_to_string(¬e_path).map_err(|e| e.to_string())?; + let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing_content); + + // Update the 'updated' timestamp + let now = Utc::now().to_rfc3339(); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now), + ); + + // Serialize with updated frontmatter and new content (atomic write) + let file_content = frontmatter::serialize_frontmatter(&fm, new_content)?; + + filesystem::atomic_write(¬e_path, file_content.as_bytes())?; + + Ok(DailyNote { + id: format!("daily-{}", date), + date: date.to_string(), + path: format!("daily/{}.md", date), + content: new_content.to_string(), + frontmatter: fm, + }) +} diff --git a/backend/src/routes/git.rs b/backend/src/routes/git.rs new file mode 100644 index 0000000..60daaad --- /dev/null +++ b/backend/src/routes/git.rs @@ -0,0 +1,184 @@ +use axum::{ + extract::{Path, Query}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::services::git; + +pub fn router() -> Router { + Router::new() + .route("/status", get(get_status)) + .route("/commit", post(commit)) + .route("/init", post(init_repo)) + .route("/conflicts", get(get_conflicts)) + .route("/push", post(push)) + .route("/log", get(get_log)) + .route("/diff", get(get_working_diff)) + .route("/diff/{commit_id}", get(get_commit_diff)) + .route("/remote", get(get_remote)) + .route("/fetch", post(fetch)) +} + +async fn get_status() -> impl IntoResponse { + match git::get_status() { + Ok(status) => Json(status).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get git status: {}", err), + ) + .into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct CommitRequest { + message: Option, +} + +async fn commit(Json(payload): Json) -> impl IntoResponse { + match git::commit_all(payload.message.as_deref()) { + Ok(info) => (StatusCode::CREATED, Json(info)).into_response(), + Err(err) => (StatusCode::BAD_REQUEST, err).into_response(), + } +} + +async fn init_repo() -> impl IntoResponse { + match git::init_repo() { + Ok(_) => StatusCode::OK.into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to init repo: {}", err), + ) + .into_response(), + } +} + +async fn get_conflicts() -> impl IntoResponse { + match git::check_conflicts() { + Ok(conflicts) => Json(conflicts).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to check conflicts: {}", err), + ) + .into_response(), + } +} + +#[derive(Debug, Serialize)] +struct PushResponse { + success: bool, + message: String, +} + +async fn push() -> impl IntoResponse { + // Check if remote is configured + if !git::has_remote() { + return ( + StatusCode::BAD_REQUEST, + Json(PushResponse { + success: false, + message: "No remote repository configured. Add a remote with: git remote add origin ".to_string(), + }), + ) + .into_response(); + } + + match git::push_to_remote() { + Ok(()) => ( + StatusCode::OK, + Json(PushResponse { + success: true, + message: "Successfully pushed to remote".to_string(), + }), + ) + .into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(PushResponse { + success: false, + message: err, + }), + ) + .into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct LogQuery { + limit: Option, +} + +async fn get_log(Query(query): Query) -> impl IntoResponse { + match git::get_log(query.limit) { + Ok(commits) => Json(commits).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get git log: {}", err), + ) + .into_response(), + } +} + +async fn get_working_diff() -> impl IntoResponse { + match git::get_working_diff() { + Ok(diff) => Json(diff).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get diff: {}", err), + ) + .into_response(), + } +} + +async fn get_commit_diff(Path(commit_id): Path) -> impl IntoResponse { + match git::get_commit_diff(&commit_id) { + Ok(diff) => Json(diff).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get commit diff: {}", err), + ) + .into_response(), + } +} + +async fn get_remote() -> impl IntoResponse { + match git::get_remote_info() { + Ok(info) => Json(info).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get remote info: {}", err), + ) + .into_response(), + } +} + +#[derive(Debug, Serialize)] +struct FetchResponse { + success: bool, + message: String, +} + +async fn fetch() -> impl IntoResponse { + match git::fetch_from_remote() { + Ok(()) => ( + StatusCode::OK, + Json(FetchResponse { + success: true, + message: "Successfully fetched from remote".to_string(), + }), + ) + .into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(FetchResponse { + success: false, + message: err, + }), + ) + .into_response(), + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..9652ed2 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,7 @@ +pub mod assets; +pub mod daily; +pub mod git; +pub mod notes; +pub mod projects; +pub mod search; +pub mod tasks; \ No newline at end of file diff --git a/backend/src/routes/notes.rs b/backend/src/routes/notes.rs new file mode 100644 index 0000000..d11152b --- /dev/null +++ b/backend/src/routes/notes.rs @@ -0,0 +1,82 @@ +use axum::{ + extract::Path, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; + +use crate::models::note::{Note, NoteSummary}; +use crate::services::filesystem; + +pub fn router() -> Router { + Router::new() + .route("/{id}", get(get_note).put(update_note).delete(delete_note)) +} + +pub async fn list_notes() -> impl IntoResponse { + match filesystem::list_notes() { + Ok(notes) => Json::>(notes).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to list notes: {}", err), + ) + .into_response(), + } +} + +async fn get_note(Path(id): Path) -> impl IntoResponse { + match filesystem::read_note_by_id(&id) { + Ok(note) => Json::(note).into_response(), + Err(err) if err.starts_with("Note not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read note: {}", err), + ) + .into_response(), + } +} + +pub async fn create_note() -> impl IntoResponse { + match filesystem::create_note() { + Ok(note) => (StatusCode::CREATED, Json::(note)).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create note: {}", err), + ) + .into_response(), + } +} + +async fn update_note( + Path(id): Path, + body: String, +) -> impl IntoResponse { + match filesystem::update_note(&id, &body) { + Ok(note) => Json::(note).into_response(), + Err(err) if err.starts_with("Note not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update note: {}", err), + ) + .into_response(), + } +} + +async fn delete_note(Path(id): Path) -> impl IntoResponse { + match filesystem::archive_note(&id) { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(err) if err.starts_with("Note not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to archive note: {}", err), + ) + .into_response(), + } +} diff --git a/backend/src/routes/projects.rs b/backend/src/routes/projects.rs new file mode 100644 index 0000000..85ce8db --- /dev/null +++ b/backend/src/routes/projects.rs @@ -0,0 +1,860 @@ +use axum::{ + extract::Path, + http::StatusCode, + response::IntoResponse, + routing::{get, put}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::fs; + +use crate::routes::tasks::{ + CreateTaskRequest, UpdateTaskMetaRequest, + list_project_tasks_handler, create_task_handler, get_task_handler, + update_task_content_handler, toggle_task_handler, update_task_meta_handler, + delete_task_handler, +}; +use crate::services::filesystem; +use crate::config; +use crate::services::frontmatter; + +#[derive(Debug, Serialize)] +pub struct Project { + pub id: String, + pub name: String, + pub path: String, + pub created: String, +} + +#[derive(Debug, Serialize)] +pub struct ProjectWithContent { + pub id: String, + pub name: String, + pub path: String, + pub created: String, + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateProjectContentRequest { + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateProjectRequest { + pub name: String, +} + +#[derive(Debug, Serialize)] +pub struct ProjectNote { + pub id: String, + pub title: String, + pub path: String, + pub project_id: String, + pub created: String, + pub updated: String, +} + +#[derive(Debug, Serialize)] +pub struct ProjectNoteWithContent { + pub id: String, + pub title: String, + pub path: String, + pub project_id: String, + pub created: String, + pub updated: String, + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateNoteRequest { + pub title: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/", get(list_projects).post(create_project)) + .route("/{id}", get(get_project)) + .route("/{id}/content", get(get_project_content).put(update_project_content)) + // Task routes (file-based) + .route("/{id}/tasks", get(get_project_tasks).post(create_project_task)) + .route("/{id}/tasks/{task_id}", get(get_project_task).put(update_project_task).delete(delete_project_task)) + .route("/{id}/tasks/{task_id}/toggle", put(toggle_project_task)) + .route("/{id}/tasks/{task_id}/meta", put(update_project_task_meta)) + // Note routes + .route("/{id}/notes", get(list_project_notes).post(create_project_note)) + .route("/{id}/notes/{note_id}", get(get_project_note).put(update_project_note).delete(delete_project_note)) +} + +// ============ Task Handlers ============ + +async fn get_project_tasks(Path(id): Path) -> impl IntoResponse { + list_project_tasks_handler(id).await +} + +async fn create_project_task( + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + create_task_handler(id, payload).await +} + +async fn get_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse { + get_task_handler(id, task_id).await +} + +async fn update_project_task( + Path((id, task_id)): Path<(String, String)>, + body: String, +) -> impl IntoResponse { + update_task_content_handler(id, task_id, body).await +} + +async fn toggle_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse { + toggle_task_handler(id, task_id).await +} + +async fn update_project_task_meta( + Path((id, task_id)): Path<(String, String)>, + Json(payload): Json, +) -> impl IntoResponse { + update_task_meta_handler(id, task_id, payload).await +} + +async fn delete_project_task(Path((id, task_id)): Path<(String, String)>) -> impl IntoResponse { + delete_task_handler(id, task_id).await +} + +async fn list_projects() -> impl IntoResponse { + match list_projects_impl() { + Ok(projects) => Json(projects).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to list projects: {}", err), + ) + .into_response(), + } +} + +fn list_projects_impl() -> Result, String> { + let projects_dir = config::data_dir().join("projects"); + + if !projects_dir.exists() { + return Ok(Vec::new()); + } + + let mut projects = Vec::new(); + + for entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let index_path = path.join("index.md"); + if !index_path.exists() { + continue; + } + + let content = fs::read_to_string(&index_path).map_err(|e| e.to_string())?; + let (fm, _, _) = frontmatter::parse_frontmatter(&content); + + let id = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + let name = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| id.clone()); + + let created = fm + .get(&serde_yaml::Value::from("created")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + projects.push(Project { + id: id.clone(), + name, + path: format!("projects/{}", id), + created, + }); + } + + Ok(projects) +} + +async fn get_project(Path(id): Path) -> impl IntoResponse { + let projects_dir = config::data_dir().join("projects").join(&id); + let index_path = projects_dir.join("index.md"); + + if !index_path.exists() { + return (StatusCode::NOT_FOUND, "Project not found").into_response(); + } + + match fs::read_to_string(&index_path) { + Ok(content) => { + let (fm, _, _) = frontmatter::parse_frontmatter(&content); + + let name = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| id.clone()); + + let created = fm + .get(&serde_yaml::Value::from("created")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + Json(Project { + id: id.clone(), + name, + path: format!("projects/{}", id), + created, + }) + .into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read project: {}", err), + ) + .into_response(), + } +} + +async fn create_project(Json(payload): Json) -> impl IntoResponse { + match create_project_impl(&payload.name) { + Ok(project) => (StatusCode::CREATED, Json(project)).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create project: {}", err), + ) + .into_response(), + } +} + +fn create_project_impl(name: &str) -> Result { + use chrono::Utc; + + // Create slug from name + let slug = name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + + if slug.is_empty() { + return Err("Invalid project name".to_string()); + } + + let projects_dir = config::data_dir().join("projects"); + let project_dir = projects_dir.join(&slug); + + if project_dir.exists() { + return Err("Project already exists".to_string()); + } + + // Create directories + fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?; + fs::create_dir_all(project_dir.join("assets")).map_err(|e| e.to_string())?; + + // Create index.md + let index_path = project_dir.join("index.md"); + let now = Utc::now().to_rfc3339(); + + let mut fm = serde_yaml::Mapping::new(); + fm.insert( + serde_yaml::Value::from("id"), + serde_yaml::Value::from(format!("{}-index", slug)), + ); + fm.insert( + serde_yaml::Value::from("type"), + serde_yaml::Value::from("project"), + ); + fm.insert( + serde_yaml::Value::from("title"), + serde_yaml::Value::from(name), + ); + fm.insert( + serde_yaml::Value::from("created"), + serde_yaml::Value::from(now.clone()), + ); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now.clone()), + ); + + let content = frontmatter::serialize_frontmatter(&fm, &format!("# {}\n\n", name))?; + + filesystem::atomic_write(&index_path, content.as_bytes())?; + + // Also create notes directory for project-scoped notes + fs::create_dir_all(project_dir.join("notes")).map_err(|e| e.to_string())?; + + // Create tasks directory for file-based tasks + fs::create_dir_all(project_dir.join("tasks")).map_err(|e| e.to_string())?; + + Ok(Project { + id: slug.clone(), + name: name.to_string(), + path: format!("projects/{}", slug), + created: now, + }) +} + +async fn get_project_content(Path(id): Path) -> impl IntoResponse { + let index_path = config::data_dir() + .join("projects") + .join(&id) + .join("index.md"); + + if !index_path.exists() { + return (StatusCode::NOT_FOUND, "Project not found").into_response(); + } + + match fs::read_to_string(&index_path) { + Ok(content) => { + let (fm, body, _) = frontmatter::parse_frontmatter(&content); + + let name = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| id.clone()); + + let created = fm + .get(&serde_yaml::Value::from("created")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + Json(ProjectWithContent { + id: id.clone(), + name, + path: format!("projects/{}", id), + created, + content: body, + }) + .into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read project: {}", err), + ) + .into_response(), + } +} + +async fn update_project_content( + Path(id): Path, + body: String, +) -> impl IntoResponse { + let index_path = config::data_dir() + .join("projects") + .join(&id) + .join("index.md"); + + if !index_path.exists() { + return (StatusCode::NOT_FOUND, "Project not found").into_response(); + } + + // Read existing file to get frontmatter + let existing = match fs::read_to_string(&index_path) { + Ok(content) => content, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read project: {}", err), + ) + .into_response(); + } + }; + + let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing); + + // Update the timestamp + let now = chrono::Utc::now().to_rfc3339(); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now), + ); + + // Serialize with new content + let new_content = match frontmatter::serialize_frontmatter(&fm, &body) { + Ok(c) => c, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to serialize: {}", err), + ) + .into_response(); + } + }; + + // Write back (atomic to prevent corruption) + if let Err(err) = filesystem::atomic_write(&index_path, new_content.as_bytes()) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to write file: {}", err), + ) + .into_response(); + } + + let name = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| id.clone()); + + let created = fm + .get(&serde_yaml::Value::from("created")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + Json(ProjectWithContent { + id: id.clone(), + name, + path: format!("projects/{}", id), + created, + content: body, + }) + .into_response() +} + +// ============ Project Notes Handlers ============ + +async fn list_project_notes(Path(project_id): Path) -> impl IntoResponse { + let notes_dir = config::data_dir() + .join("projects") + .join(&project_id) + .join("notes"); + + // Create notes directory if it doesn't exist + if !notes_dir.exists() { + if let Err(e) = fs::create_dir_all(¬es_dir) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create notes directory: {}", e), + ) + .into_response(); + } + } + + let mut notes = Vec::new(); + + let entries = match fs::read_dir(¬es_dir) { + Ok(e) => e, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read notes directory: {}", err), + ) + .into_response(); + } + }; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + + let (fm, _, _) = frontmatter::parse_frontmatter(&content); + + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + let id = fm + .get(&serde_yaml::Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| filename.clone()); + + let title = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| filename.clone()); + + let created = fm + .get(&serde_yaml::Value::from("created")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + let updated = fm + .get(&serde_yaml::Value::from("updated")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + notes.push(ProjectNote { + id, + title, + path: format!("projects/{}/notes/{}.md", project_id, filename), + project_id: project_id.clone(), + created, + updated, + }); + } + + // Sort by updated date descending + // Sort by created date (stable ordering - won't change when note is viewed/edited) + notes.sort_by(|a, b| b.created.cmp(&a.created)); + + Json(notes).into_response() +} + +async fn create_project_note( + Path(project_id): Path, + Json(payload): Json, +) -> impl IntoResponse { + use chrono::Utc; + + let notes_dir = config::data_dir() + .join("projects") + .join(&project_id) + .join("notes"); + + // Create notes directory if it doesn't exist + if let Err(e) = fs::create_dir_all(¬es_dir) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create notes directory: {}", e), + ) + .into_response(); + } + + // Generate filename from timestamp + let now = Utc::now(); + let filename = now.format("%Y%m%d-%H%M%S").to_string(); + let note_path = notes_dir.join(format!("{}.md", filename)); + + let title = payload.title.unwrap_or_else(|| "Untitled".to_string()); + let now_str = now.to_rfc3339(); + + let mut fm = serde_yaml::Mapping::new(); + fm.insert( + serde_yaml::Value::from("id"), + serde_yaml::Value::from(format!("{}-{}", project_id, filename)), + ); + fm.insert( + serde_yaml::Value::from("type"), + serde_yaml::Value::from("note"), + ); + fm.insert( + serde_yaml::Value::from("title"), + serde_yaml::Value::from(title.clone()), + ); + fm.insert( + serde_yaml::Value::from("project_id"), + serde_yaml::Value::from(project_id.clone()), + ); + fm.insert( + serde_yaml::Value::from("created"), + serde_yaml::Value::from(now_str.clone()), + ); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now_str.clone()), + ); + + let body = format!("# {}\n\n", title); + let content = match frontmatter::serialize_frontmatter(&fm, &body) { + Ok(c) => c, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to serialize frontmatter: {}", err), + ) + .into_response(); + } + }; + + if let Err(err) = filesystem::atomic_write(¬e_path, content.as_bytes()) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to write note file: {}", err), + ) + .into_response(); + } + + ( + StatusCode::CREATED, + Json(ProjectNoteWithContent { + id: format!("{}-{}", project_id, filename), + title, + path: format!("projects/{}/notes/{}.md", project_id, filename), + project_id, + created: now_str.clone(), + updated: now_str, + content: body, + }), + ) + .into_response() +} + +async fn get_project_note(Path((project_id, note_id)): Path<(String, String)>) -> impl IntoResponse { + let notes_dir = config::data_dir() + .join("projects") + .join(&project_id) + .join("notes"); + + // Try to find the note by ID (which might be the filename) + let note_path = notes_dir.join(format!("{}.md", note_id)); + + if !note_path.exists() { + // Try to find by searching all notes for matching ID + if let Ok(entries) = fs::read_dir(¬es_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + if let Ok(content) = fs::read_to_string(&path) { + let (fm, body, _) = frontmatter::parse_frontmatter(&content); + + let file_id = fm + .get(&serde_yaml::Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from); + + if file_id.as_deref() == Some(¬e_id) { + let title = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + let created = fm + .get(&serde_yaml::Value::from("created")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + let updated = fm + .get(&serde_yaml::Value::from("updated")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + return Json(ProjectNoteWithContent { + id: note_id, + title, + path: format!("projects/{}/notes/{}.md", project_id, filename), + project_id, + created, + updated, + content: body, + }) + .into_response(); + } + } + } + } + + return (StatusCode::NOT_FOUND, "Note not found").into_response(); + } + + let content = match fs::read_to_string(¬e_path) { + Ok(c) => c, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read note: {}", err), + ) + .into_response(); + } + }; + + let (fm, body, _) = frontmatter::parse_frontmatter(&content); + + let id = fm + .get(&serde_yaml::Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| note_id.clone()); + + let title = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + let created = fm + .get(&serde_yaml::Value::from("created")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + let updated = fm + .get(&serde_yaml::Value::from("updated")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + Json(ProjectNoteWithContent { + id, + title, + path: format!("projects/{}/notes/{}.md", project_id, note_id), + project_id, + created, + updated, + content: body, + }) + .into_response() +} + +async fn update_project_note( + Path((project_id, note_id)): Path<(String, String)>, + body: String, +) -> impl IntoResponse { + let notes_dir = config::data_dir() + .join("projects") + .join(&project_id) + .join("notes"); + + let note_path = notes_dir.join(format!("{}.md", note_id)); + + if !note_path.exists() { + return (StatusCode::NOT_FOUND, "Note not found").into_response(); + } + + // Read existing content for frontmatter + let existing = match fs::read_to_string(¬e_path) { + Ok(c) => c, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to read note: {}", err), + ) + .into_response(); + } + }; + + let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing); + + // Update timestamp + let now = chrono::Utc::now().to_rfc3339(); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now.clone()), + ); + + // Serialize with new content + let new_content = match frontmatter::serialize_frontmatter(&fm, &body) { + Ok(c) => c, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to serialize: {}", err), + ) + .into_response(); + } + }; + + if let Err(err) = filesystem::atomic_write(¬e_path, new_content.as_bytes()) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to write file: {}", err), + ) + .into_response(); + } + + let id = fm + .get(&serde_yaml::Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| note_id.clone()); + + let title = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + let created = fm + .get(&serde_yaml::Value::from("created")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + + Json(ProjectNoteWithContent { + id, + title, + path: format!("projects/{}/notes/{}.md", project_id, note_id), + project_id, + created, + updated: now, + content: body, + }) + .into_response() +} + +async fn delete_project_note( + Path((project_id, note_id)): Path<(String, String)>, +) -> impl IntoResponse { + let notes_dir = config::data_dir() + .join("projects") + .join(&project_id) + .join("notes"); + + let note_path = notes_dir.join(format!("{}.md", note_id)); + + if !note_path.exists() { + return (StatusCode::NOT_FOUND, "Note not found").into_response(); + } + + // Move to archive instead of deleting + let archive_dir = config::data_dir().join("archive"); + if let Err(e) = fs::create_dir_all(&archive_dir) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create archive directory: {}", e), + ) + .into_response(); + } + + let archive_path = archive_dir.join(format!("{}-{}.md", project_id, note_id)); + + if let Err(err) = fs::rename(¬e_path, &archive_path) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to archive note: {}", err), + ) + .into_response(); + } + + StatusCode::NO_CONTENT.into_response() +} diff --git a/backend/src/routes/search.rs b/backend/src/routes/search.rs new file mode 100644 index 0000000..cd9faad --- /dev/null +++ b/backend/src/routes/search.rs @@ -0,0 +1,30 @@ +use axum::{ + extract::Query, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use serde::Deserialize; + +use crate::services::search; + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + q: String, +} + +pub fn router() -> Router { + Router::new().route("/", get(search_notes)) +} + +async fn search_notes(Query(params): Query) -> impl IntoResponse { + match search::search_notes(¶ms.q) { + Ok(results) => Json(results).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Search failed: {}", err), + ) + .into_response(), + } +} diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs new file mode 100644 index 0000000..49bb734 --- /dev/null +++ b/backend/src/routes/tasks.rs @@ -0,0 +1,835 @@ +use axum::{ + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path as StdPath; + +use crate::services::filesystem; +use crate::config; +use crate::services::frontmatter; + +/// Task summary for list views +#[derive(Debug, Clone, Serialize)] +pub struct Task { + pub id: String, + pub title: String, + pub completed: bool, + pub section: String, + pub priority: Option, + pub due_date: Option, + pub is_active: bool, + pub tags: Vec, + pub parent_id: Option, + pub recurrence: Option, + pub recurrence_interval: Option, + pub project_id: String, + pub path: String, + pub created: String, + pub updated: String, +} + +/// Task with full content for detail view +#[derive(Debug, Clone, Serialize)] +pub struct TaskWithContent { + pub id: String, + pub title: String, + pub completed: bool, + pub section: String, + pub priority: Option, + pub due_date: Option, + pub is_active: bool, + pub tags: Vec, + pub parent_id: Option, + pub recurrence: Option, + pub recurrence_interval: Option, + pub project_id: String, + pub path: String, + pub created: String, + pub updated: String, + pub content: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateTaskRequest { + pub title: String, + pub section: Option, + pub parent_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateTaskMetaRequest { + pub title: Option, + pub section: Option, + pub priority: Option, + pub due_date: Option, + pub is_active: Option, + pub tags: Option>, + pub recurrence: Option, + pub recurrence_interval: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/", get(list_all_tasks_handler)) +} + +// ============ Handler Functions (called from projects.rs) ============ + +/// List all tasks for a project +pub async fn list_project_tasks_handler(project_id: String) -> impl IntoResponse { + match list_project_tasks_impl(&project_id) { + Ok(tasks) => Json(tasks).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to list tasks: {}", err), + ) + .into_response(), + } +} + +/// Create a new task +pub async fn create_task_handler( + project_id: String, + payload: CreateTaskRequest, +) -> impl IntoResponse { + match create_task_impl(&project_id, &payload.title, payload.section.as_deref(), payload.parent_id.as_deref()) { + Ok(task) => (StatusCode::CREATED, Json(task)).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create task: {}", err), + ) + .into_response(), + } +} + +/// Get a task with content +pub async fn get_task_handler(project_id: String, task_id: String) -> impl IntoResponse { + match get_task_impl(&project_id, &task_id) { + Ok(task) => Json(task).into_response(), + Err(err) if err.contains("not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get task: {}", err), + ) + .into_response(), + } +} + +/// Update task content (markdown body) +pub async fn update_task_content_handler( + project_id: String, + task_id: String, + body: String, +) -> impl IntoResponse { + match update_task_content_impl(&project_id, &task_id, &body) { + Ok(task) => Json(task).into_response(), + Err(err) if err.contains("not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update task: {}", err), + ) + .into_response(), + } +} + +/// Toggle task completion +pub async fn toggle_task_handler(project_id: String, task_id: String) -> impl IntoResponse { + match toggle_task_impl(&project_id, &task_id) { + Ok(task) => Json(task).into_response(), + Err(err) if err.contains("not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to toggle task: {}", err), + ) + .into_response(), + } +} + +/// Update task metadata (title, section, priority) +pub async fn update_task_meta_handler( + project_id: String, + task_id: String, + payload: UpdateTaskMetaRequest, +) -> impl IntoResponse { + match update_task_meta_impl(&project_id, &task_id, payload) { + Ok(task) => Json(task).into_response(), + Err(err) if err.contains("not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to update task metadata: {}", err), + ) + .into_response(), + } +} + +/// Delete (archive) a task +pub async fn delete_task_handler(project_id: String, task_id: String) -> impl IntoResponse { + match delete_task_impl(&project_id, &task_id) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) if err.contains("not found") => { + (StatusCode::NOT_FOUND, err).into_response() + } + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to delete task: {}", err), + ) + .into_response(), + } +} + +// ============ Implementation Functions ============ + +fn get_tasks_dir(project_id: &str) -> std::path::PathBuf { + config::data_dir() + .join("projects") + .join(project_id) + .join("tasks") +} + +fn ensure_tasks_dir(project_id: &str) -> Result { + let tasks_dir = get_tasks_dir(project_id); + if !tasks_dir.exists() { + fs::create_dir_all(&tasks_dir).map_err(|e| e.to_string())?; + } + Ok(tasks_dir) +} + +fn list_project_tasks_impl(project_id: &str) -> Result, String> { + let tasks_dir = ensure_tasks_dir(project_id)?; + + let mut tasks = Vec::new(); + + let entries = match fs::read_dir(&tasks_dir) { + Ok(e) => e, + Err(_) => return Ok(Vec::new()), // No tasks directory yet + }; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + + if let Some(task) = parse_task_file(&content, &path, project_id) { + tasks.push(task); + } + } + + // Sort by updated date descending (most recent first) + // Sort by created date (stable ordering - won't change when task is viewed/edited) + tasks.sort_by(|a, b| b.created.cmp(&a.created)); + + Ok(tasks) +} + +/// Shared helper: extract common task fields from frontmatter. +/// Eliminates duplication between parse_task_file and parse_task_with_content. +fn extract_task_fields(fm: &serde_yaml::Mapping, path: &StdPath, project_id: &str) -> Task { + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + Task { + id: frontmatter::get_str_or(fm, "id", &filename), + title: frontmatter::get_str_or(fm, "title", "Untitled"), + completed: frontmatter::get_bool_or(fm, "completed", false), + section: frontmatter::get_str_or(fm, "section", "Active"), + priority: frontmatter::get_str(fm, "priority"), + due_date: frontmatter::get_str(fm, "due_date"), + is_active: frontmatter::get_bool_or(fm, "is_active", true), + tags: frontmatter::get_string_seq(fm, "tags"), + parent_id: frontmatter::get_str(fm, "parent_id"), + recurrence: frontmatter::get_str(fm, "recurrence"), + recurrence_interval: frontmatter::get_u64(fm, "recurrence_interval").map(|v| v as u32), + project_id: project_id.to_string(), + path: format!("projects/{}/tasks/{}.md", project_id, filename), + created: frontmatter::get_str_or(fm, "created", ""), + updated: frontmatter::get_str_or(fm, "updated", ""), + } +} + +fn parse_task_file(content: &str, path: &StdPath, project_id: &str) -> Option { + let (fm, _, _) = frontmatter::parse_frontmatter(content); + Some(extract_task_fields(&fm, path, project_id)) +} + +fn create_task_impl( + project_id: &str, + title: &str, + section: Option<&str>, + parent_id: Option<&str>, +) -> Result { + use chrono::Utc; + + let tasks_dir = ensure_tasks_dir(project_id)?; + + // Generate filename from timestamp + let now = Utc::now(); + let filename = format!("task-{}", now.format("%Y%m%d-%H%M%S")); + let task_path = tasks_dir.join(format!("{}.md", filename)); + + let section = section.unwrap_or("Active").to_string(); + let now_str = now.to_rfc3339(); + let id = format!("{}-{}", project_id, filename); + + let mut fm = serde_yaml::Mapping::new(); + fm.insert( + serde_yaml::Value::from("id"), + serde_yaml::Value::from(id.clone()), + ); + fm.insert( + serde_yaml::Value::from("type"), + serde_yaml::Value::from("task"), + ); + fm.insert( + serde_yaml::Value::from("title"), + serde_yaml::Value::from(title), + ); + fm.insert( + serde_yaml::Value::from("completed"), + serde_yaml::Value::from(false), + ); + fm.insert( + serde_yaml::Value::from("section"), + serde_yaml::Value::from(section.clone()), + ); + fm.insert( + serde_yaml::Value::from("priority"), + serde_yaml::Value::from("normal"), + ); + fm.insert( + serde_yaml::Value::from("is_active"), + serde_yaml::Value::from(true), + ); + fm.insert( + serde_yaml::Value::from("project_id"), + serde_yaml::Value::from(project_id), + ); + if let Some(pid) = parent_id { + fm.insert( + serde_yaml::Value::from("parent_id"), + serde_yaml::Value::from(pid), + ); + } + fm.insert( + serde_yaml::Value::from("created"), + serde_yaml::Value::from(now_str.clone()), + ); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now_str.clone()), + ); + + let body = format!("# {}\n\n", title); + let content = frontmatter::serialize_frontmatter(&fm, &body)?; + + filesystem::atomic_write(&task_path, content.as_bytes())?; + + Ok(TaskWithContent { + id, + title: title.to_string(), + completed: false, + section, + priority: Some("normal".to_string()), + due_date: None, + is_active: true, + tags: Vec::new(), + parent_id: parent_id.map(String::from), + recurrence: None, + recurrence_interval: None, + project_id: project_id.to_string(), + path: format!("projects/{}/tasks/{}.md", project_id, filename), + created: now_str.clone(), + updated: now_str, + content: body, + }) +} + +fn get_task_impl(project_id: &str, task_id: &str) -> Result { + let tasks_dir = get_tasks_dir(project_id); + + // Try direct filename match first + let task_path = tasks_dir.join(format!("{}.md", task_id)); + + if task_path.exists() { + return read_task_with_content(&task_path, project_id); + } + + // Search by ID in frontmatter + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + if let Ok(content) = fs::read_to_string(&path) { + let (fm, body, _) = frontmatter::parse_frontmatter(&content); + + let file_id = fm + .get(&serde_yaml::Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from); + + if file_id.as_deref() == Some(task_id) { + return parse_task_with_content(&fm, &body, &path, project_id); + } + } + } + } + + Err("Task not found".to_string()) +} + +fn read_task_with_content(path: &StdPath, project_id: &str) -> Result { + let content = fs::read_to_string(path).map_err(|e| e.to_string())?; + let (fm, body, _) = frontmatter::parse_frontmatter(&content); + parse_task_with_content(&fm, &body, path, project_id) +} + +fn parse_task_with_content( + fm: &serde_yaml::Mapping, + body: &str, + path: &StdPath, + project_id: &str, +) -> Result { + let task = extract_task_fields(fm, path, project_id); + Ok(TaskWithContent { + id: task.id, + title: task.title, + completed: task.completed, + section: task.section, + priority: task.priority, + due_date: task.due_date, + is_active: task.is_active, + tags: task.tags, + parent_id: task.parent_id, + recurrence: task.recurrence, + recurrence_interval: task.recurrence_interval, + project_id: task.project_id, + path: task.path, + created: task.created, + updated: task.updated, + content: body.to_string(), + }) +} + +fn update_task_content_impl( + project_id: &str, + task_id: &str, + new_body: &str, +) -> Result { + let task_path = find_task_path(project_id, task_id)?; + + // Read existing content + let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?; + let (mut fm, _, _) = frontmatter::parse_frontmatter(&existing); + + // Update timestamp + let now = chrono::Utc::now().to_rfc3339(); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now), + ); + + // Serialize with new content (atomic write to prevent corruption) + let new_content = frontmatter::serialize_frontmatter(&fm, new_body)?; + filesystem::atomic_write(&task_path, new_content.as_bytes())?; + + parse_task_with_content(&fm, new_body, &task_path, project_id) +} + +fn toggle_task_impl(project_id: &str, task_id: &str) -> Result { + let task_path = find_task_path(project_id, task_id)?; + + // Read existing content + let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?; + let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing); + + // Toggle completed + let current_completed = fm + .get(&serde_yaml::Value::from("completed")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let new_completed = !current_completed; + fm.insert( + serde_yaml::Value::from("completed"), + serde_yaml::Value::from(new_completed), + ); + + // Update section based on completion status + let new_section = if new_completed { + "Completed" + } else { + "Active" + }; + fm.insert( + serde_yaml::Value::from("section"), + serde_yaml::Value::from(new_section), + ); + + // Update timestamp + let now = chrono::Utc::now().to_rfc3339(); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now), + ); + + // Serialize and write (atomic to prevent corruption) + let new_content = frontmatter::serialize_frontmatter(&fm, &body)?; + filesystem::atomic_write(&task_path, new_content.as_bytes())?; + + // If completing a recurring task, create the next instance + if new_completed { + let recurrence = fm + .get(&serde_yaml::Value::from("recurrence")) + .and_then(|v| v.as_str()) + .map(String::from); + + if let Some(rec) = recurrence { + let interval = fm + .get(&serde_yaml::Value::from("recurrence_interval")) + .and_then(|v| v.as_u64()) + .unwrap_or(1) as i64; + + let title = fm + .get(&serde_yaml::Value::from("title")) + .and_then(|v| v.as_str()) + .unwrap_or("Untitled") + .to_string(); + + let due_date = fm + .get(&serde_yaml::Value::from("due_date")) + .and_then(|v| v.as_str()) + .map(String::from); + + let tags = fm + .get(&serde_yaml::Value::from("tags")) + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + + // Calculate next due date + let next_due = calculate_next_due_date(due_date.as_deref(), &rec, interval); + + // Create the next recurring task + let _ = create_recurring_task_impl( + project_id, + &title, + next_due.as_deref(), + &rec, + interval as u32, + &tags, + ); + } + } + + // Return updated task + let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id) + .ok_or_else(|| "Failed to parse updated task".to_string())?; + + Ok(task) +} + +fn calculate_next_due_date(current_due: Option<&str>, recurrence: &str, interval: i64) -> Option { + use chrono::{NaiveDate, Duration, Utc, Months}; + + let base_date = if let Some(due_str) = current_due { + NaiveDate::parse_from_str(due_str, "%Y-%m-%d").unwrap_or_else(|_| Utc::now().date_naive()) + } else { + Utc::now().date_naive() + }; + + let next = match recurrence { + "daily" => Some(base_date + Duration::days(interval)), + "weekly" => Some(base_date + Duration::weeks(interval)), + "monthly" => base_date.checked_add_months(Months::new(interval as u32)), + "yearly" => base_date.checked_add_months(Months::new((interval * 12) as u32)), + _ => None, + }; + + next.map(|d| d.format("%Y-%m-%d").to_string()) +} + +fn create_recurring_task_impl( + project_id: &str, + title: &str, + due_date: Option<&str>, + recurrence: &str, + interval: u32, + tags: &[String], +) -> Result { + use chrono::Utc; + + let tasks_dir = ensure_tasks_dir(project_id)?; + let now = Utc::now(); + // Add a small suffix to avoid filename collision with completed task + let filename = format!("task-{}-r", now.format("%Y%m%d-%H%M%S")); + let task_path = tasks_dir.join(format!("{}.md", filename)); + + let now_str = now.to_rfc3339(); + let id = format!("{}-{}", project_id, filename); + + let mut fm = serde_yaml::Mapping::new(); + fm.insert(serde_yaml::Value::from("id"), serde_yaml::Value::from(id.clone())); + fm.insert(serde_yaml::Value::from("type"), serde_yaml::Value::from("task")); + fm.insert(serde_yaml::Value::from("title"), serde_yaml::Value::from(title)); + fm.insert(serde_yaml::Value::from("completed"), serde_yaml::Value::from(false)); + fm.insert(serde_yaml::Value::from("section"), serde_yaml::Value::from("Active")); + fm.insert(serde_yaml::Value::from("priority"), serde_yaml::Value::from("normal")); + fm.insert(serde_yaml::Value::from("is_active"), serde_yaml::Value::from(true)); + fm.insert(serde_yaml::Value::from("project_id"), serde_yaml::Value::from(project_id)); + fm.insert(serde_yaml::Value::from("recurrence"), serde_yaml::Value::from(recurrence)); + fm.insert(serde_yaml::Value::from("recurrence_interval"), serde_yaml::Value::from(interval as u64)); + + if let Some(due) = due_date { + fm.insert(serde_yaml::Value::from("due_date"), serde_yaml::Value::from(due)); + } + + if !tags.is_empty() { + let yaml_tags: Vec = tags.iter().map(|t| serde_yaml::Value::from(t.as_str())).collect(); + fm.insert(serde_yaml::Value::from("tags"), serde_yaml::Value::Sequence(yaml_tags)); + } + + fm.insert(serde_yaml::Value::from("created"), serde_yaml::Value::from(now_str.clone())); + fm.insert(serde_yaml::Value::from("updated"), serde_yaml::Value::from(now_str.clone())); + + let body = format!("# {}\n\n", title); + let content = frontmatter::serialize_frontmatter(&fm, &body)?; + + filesystem::atomic_write(&task_path, content.as_bytes())?; + + Ok(TaskWithContent { + id, + title: title.to_string(), + completed: false, + section: "Active".to_string(), + priority: Some("normal".to_string()), + due_date: due_date.map(String::from), + is_active: true, + tags: tags.to_vec(), + parent_id: None, + recurrence: Some(recurrence.to_string()), + recurrence_interval: Some(interval), + project_id: project_id.to_string(), + path: format!("projects/{}/tasks/{}.md", project_id, filename), + created: now_str.clone(), + updated: now_str, + content: body, + }) +} + +fn update_task_meta_impl( + project_id: &str, + task_id: &str, + meta: UpdateTaskMetaRequest, +) -> Result { + let task_path = find_task_path(project_id, task_id)?; + + // Read existing content + let existing = fs::read_to_string(&task_path).map_err(|e| e.to_string())?; + let (mut fm, body, _) = frontmatter::parse_frontmatter(&existing); + + // Update fields if provided + if let Some(title) = meta.title { + fm.insert( + serde_yaml::Value::from("title"), + serde_yaml::Value::from(title), + ); + } + if let Some(section) = meta.section { + fm.insert( + serde_yaml::Value::from("section"), + serde_yaml::Value::from(section), + ); + } + if let Some(priority) = meta.priority { + fm.insert( + serde_yaml::Value::from("priority"), + serde_yaml::Value::from(priority), + ); + } + if let Some(due_date) = meta.due_date { + fm.insert( + serde_yaml::Value::from("due_date"), + serde_yaml::Value::from(due_date), + ); + } + if let Some(is_active) = meta.is_active { + fm.insert( + serde_yaml::Value::from("is_active"), + serde_yaml::Value::from(is_active), + ); + } + if let Some(tags) = meta.tags { + let yaml_tags: Vec = + tags.into_iter().map(serde_yaml::Value::from).collect(); + fm.insert( + serde_yaml::Value::from("tags"), + serde_yaml::Value::Sequence(yaml_tags), + ); + } + if let Some(recurrence) = meta.recurrence { + if recurrence.is_empty() { + fm.remove(&serde_yaml::Value::from("recurrence")); + fm.remove(&serde_yaml::Value::from("recurrence_interval")); + } else { + fm.insert( + serde_yaml::Value::from("recurrence"), + serde_yaml::Value::from(recurrence), + ); + } + } + if let Some(interval) = meta.recurrence_interval { + fm.insert( + serde_yaml::Value::from("recurrence_interval"), + serde_yaml::Value::from(interval as u64), + ); + } + + // Update timestamp + let now = chrono::Utc::now().to_rfc3339(); + fm.insert( + serde_yaml::Value::from("updated"), + serde_yaml::Value::from(now), + ); + + // Serialize and write (atomic to prevent corruption) + let new_content = frontmatter::serialize_frontmatter(&fm, &body)?; + filesystem::atomic_write(&task_path, new_content.as_bytes())?; + + // Return updated task + let task = parse_task_file(&fs::read_to_string(&task_path).unwrap(), &task_path, project_id) + .ok_or_else(|| "Failed to parse updated task".to_string())?; + + Ok(task) +} + +fn delete_task_impl(project_id: &str, task_id: &str) -> Result<(), String> { + let task_path = find_task_path(project_id, task_id)?; + + // Move to archive + let archive_dir = config::data_dir().join("archive"); + fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?; + + let filename = task_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("task.md"); + + let archive_path = archive_dir.join(format!("{}-{}", project_id, filename)); + fs::rename(&task_path, &archive_path).map_err(|e| e.to_string())?; + + Ok(()) +} + +fn find_task_path(project_id: &str, task_id: &str) -> Result { + let tasks_dir = get_tasks_dir(project_id); + + // Try direct filename match + let direct_path = tasks_dir.join(format!("{}.md", task_id)); + if direct_path.exists() { + return Ok(direct_path); + } + + // Search by ID in frontmatter + if let Ok(entries) = fs::read_dir(&tasks_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + if let Ok(content) = fs::read_to_string(&path) { + let (fm, _, _) = frontmatter::parse_frontmatter(&content); + + let file_id = fm + .get(&serde_yaml::Value::from("id")) + .and_then(|v| v.as_str()); + + if file_id == Some(task_id) { + return Ok(path); + } + } + } + } + + Err("Task not found".to_string()) +} + +// ============ Legacy/Global Task Listing ============ + +async fn list_all_tasks_handler() -> impl IntoResponse { + match list_all_tasks_impl() { + Ok(tasks) => Json(tasks).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to list tasks: {}", err), + ) + .into_response(), + } +} + +fn list_all_tasks_impl() -> Result, String> { + let projects_dir = config::data_dir().join("projects"); + + if !projects_dir.exists() { + return Ok(Vec::new()); + } + + let mut all_tasks = Vec::new(); + + for entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let project_path = entry.path(); + + if !project_path.is_dir() { + continue; + } + + let project_id = project_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + if let Ok(tasks) = list_project_tasks_impl(&project_id) { + all_tasks.extend(tasks); + } + } + + // Sort all tasks by updated date descending + // Sort by created date (stable ordering) + all_tasks.sort_by(|a, b| b.created.cmp(&a.created)); + + Ok(all_tasks) +} diff --git a/backend/src/services/filesystem.rs b/backend/src/services/filesystem.rs new file mode 100644 index 0000000..5f2173c --- /dev/null +++ b/backend/src/services/filesystem.rs @@ -0,0 +1,349 @@ +use std::fs; +use std::io::Write; +use std::path::Path; + +use serde_yaml::Value; +use walkdir::WalkDir; + +use crate::models::note::{Note, NoteSummary}; +use crate::services::frontmatter; + +use crate::config; + +/// List all notes in the filesystem (read-only). +pub fn list_notes() -> Result, String> { + let mut notes = Vec::new(); + let root = config::data_dir(); + + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(|e| !is_ignored(e.path())) + .filter_map(Result::ok) + { + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + // Only include notes and project index files + if !is_note_file(path) { + continue; + } + + match parse_note_summary(path) { + Ok(note) => notes.push(note), + Err(err) => { + tracing::warn!("Skipping file {:?}: {}", path, err); + } + } + } + + Ok(notes) +} + +fn is_ignored(path: &Path) -> bool { + path.components().any(|c| { + matches!( + c.as_os_str().to_str(), + Some(".git") | Some("assets") | Some("archive") + ) + }) +} + +fn is_note_file(path: &Path) -> bool { + let path_str = path.to_string_lossy(); + + // data/notes/**/*.md (handles both forward and back slashes) + if path_str.contains("notes") && !path_str.contains("archive") { + return true; + } + + // data/projects/*/index.md + if path_str.contains("projects") && path.file_name().and_then(|s| s.to_str()) == Some("index.md") { + return true; + } + + // Root-level files (index.md, inbox.md) - parent is the data dir + if let Some(parent) = path.parent() { + if parent == config::data_dir() { + return true; + } + } + + false +} + +fn parse_note_summary(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| e.to_string())?; + let (fm, _body, _has_fm) = frontmatter::parse_frontmatter(&content); + + let id = fm + .get(&Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| frontmatter::derive_id_from_path(path)); + + let title = fm + .get(&Value::from("title")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string() + }); + + let note_type = fm + .get(&Value::from("type")) + .and_then(|v| v.as_str()) + .unwrap_or("note") + .to_string(); + + let updated = fm + .get(&Value::from("updated")) + .and_then(|v| v.as_str()) + .map(String::from); + + Ok(NoteSummary { + id, + title, + path: normalize_path(path), + note_type, + updated, + }) +} + +pub fn normalize_path(path: &Path) -> String { + // Strip the data directory prefix and normalize separators + let path_str = path.to_string_lossy(); + let stripped = if let Some(idx) = path_str.find("data") { + &path_str[idx + 5..] // Skip "data" + separator + } else { + &path_str + }; + stripped.replace('\\', "/").trim_start_matches('/').to_string() +} + +/// Read a full note by deterministic ID. +pub fn read_note_by_id(note_id: &str) -> Result { + let root = config::data_dir(); + + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(|e| !is_ignored(e.path())) + .filter_map(Result::ok) + { + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + if !is_note_file(path) { + continue; + } + + let content = fs::read_to_string(path).map_err(|e| e.to_string())?; + let (fm, body, _has_fm) = frontmatter::parse_frontmatter(&content); + + let derived_id = fm + .get(&Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| frontmatter::derive_id_from_path(path)); + + if derived_id != note_id { + continue; + } + + let note_type = fm + .get(&Value::from("type")) + .and_then(|v| v.as_str()) + .unwrap_or("note") + .to_string(); + + return Ok(Note { + id: derived_id, + path: normalize_path(path), + note_type, + frontmatter: fm, + content: body.trim_start().to_string(), + }); + } + + Err(format!("Note not found: {}", note_id)) +} + +/// Create a new empty note in data/notes/. +pub fn create_note() -> Result { + use chrono::Utc; + + let dir = config::data_dir().join("notes"); + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + + let filename = format!("{}.md", Utc::now().format("%Y%m%d-%H%M%S")); + let path = dir.join(&filename); + + let fm = frontmatter::generate_frontmatter(&path, "note"); + let content = frontmatter::serialize_frontmatter(&fm, "")?; + + // Atomic write: write to temp file, then rename + atomic_write(&path, content.as_bytes())?; + + let id = fm + .get(&Value::from("id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Ok(Note { + id, + path: normalize_path(&path), + note_type: "note".to_string(), + frontmatter: fm, + content: String::new(), + }) +} + +/// Update an existing note by ID with full markdown payload. +/// Handles notes with or without existing frontmatter. +/// Preserves user-defined fields, updates backend-owned fields. +pub fn update_note(note_id: &str, new_content: &str) -> Result { + let root = config::data_dir(); + + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(|e| !is_ignored(e.path())) + .filter_map(Result::ok) + { + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + if !is_note_file(path) { + continue; + } + + let content = fs::read_to_string(path).map_err(|e| e.to_string())?; + let (mut fm, _old_body, has_fm) = frontmatter::parse_frontmatter(&content); + + let derived_id = fm + .get(&Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| frontmatter::derive_id_from_path(path)); + + if derived_id != note_id { + continue; + } + + // Ensure frontmatter has all required fields + // This handles files without frontmatter or with incomplete frontmatter + if !has_fm || !frontmatter::is_frontmatter_complete(&fm) { + frontmatter::ensure_frontmatter(&mut fm, path); + } else { + // Just update the timestamp + frontmatter::update_frontmatter(&mut fm); + } + + // Rebuild file content + let rebuilt = frontmatter::serialize_frontmatter(&fm, new_content.trim_start())?; + + // Atomic write + atomic_write(path, rebuilt.as_bytes())?; + + let note_type = fm + .get(&Value::from("type")) + .and_then(|v| v.as_str()) + .unwrap_or("note") + .to_string(); + + return Ok(Note { + id: derived_id, + path: normalize_path(path), + note_type, + frontmatter: fm, + content: new_content.to_string(), + }); + } + + Err(format!("Note not found: {}", note_id)) +} + +/// Archive a note by ID (move to data/archive/). +pub fn archive_note(note_id: &str) -> Result<(), String> { + let root = config::data_dir(); + let archive_dir = config::data_dir().join("archive"); + + fs::create_dir_all(&archive_dir).map_err(|e| e.to_string())?; + + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(|e| !is_ignored(e.path())) + .filter_map(Result::ok) + { + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + if !is_note_file(path) { + continue; + } + + let content = fs::read_to_string(path).map_err(|e| e.to_string())?; + let (fm, _, _) = frontmatter::parse_frontmatter(&content); + + let derived_id = fm + .get(&Value::from("id")) + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| frontmatter::derive_id_from_path(path)); + + if derived_id != note_id { + continue; + } + + let filename = path.file_name().ok_or("Invalid filename")?; + let target = archive_dir.join(filename); + + fs::rename(path, target).map_err(|e| e.to_string())?; + return Ok(()); + } + + Err(format!("Note not found: {}", note_id)) +} + +/// Atomic write: write to temp file, then rename. +/// This prevents data loss on crash or power failure. +/// Also marks the file as recently saved to avoid triggering external edit notifications. +pub fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> { + let parent = path.parent().ok_or("Invalid path")?; + let temp_name = format!( + ".{}.tmp", + path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("file") + ); + let temp_path = parent.join(temp_name); + + // Mark this file as being saved by us (to avoid triggering external edit notification) + let normalized = normalize_path(path); + crate::watcher::mark_file_saved(&normalized); + + // Write to temp file + let mut file = fs::File::create(&temp_path).map_err(|e| e.to_string())?; + file.write_all(contents).map_err(|e| e.to_string())?; + file.sync_all().map_err(|e| e.to_string())?; + drop(file); + + // Rename temp file to target (atomic on most filesystems) + fs::rename(&temp_path, path).map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/backend/src/services/frontmatter.rs b/backend/src/services/frontmatter.rs new file mode 100644 index 0000000..9c11423 --- /dev/null +++ b/backend/src/services/frontmatter.rs @@ -0,0 +1,191 @@ +use std::path::Path; + +use chrono::Utc; +use serde_yaml::{Mapping, Value}; + +/// Derive deterministic ID from file path. +/// Matches filesystem ID logic: strips data directory prefix and folder name. +pub fn derive_id_from_path(path: &Path) -> String { + let path_str = path.to_string_lossy(); + + // Find "data" in the path and strip everything before and including it + let rel_str = if let Some(idx) = path_str.find("data") { + &path_str[idx + 5..] // Skip "data" + separator + } else { + &path_str + }; + + // Split by both forward and back slashes, filter empty parts + let mut parts: Vec = rel_str + .split(['/', '\\']) + .filter(|s| !s.is_empty()) + .map(|s| s.replace(".md", "")) + .collect(); + + // Drop top-level folder name (notes, projects, etc.) if we have multiple parts + if parts.len() > 1 { + parts.remove(0); + } + + parts.join("-") +} + +/// Parse frontmatter from file content. +/// Returns (frontmatter mapping, body content, has_frontmatter flag). +pub fn parse_frontmatter(content: &str) -> (Mapping, String, bool) { + if !content.starts_with("---") { + return (Mapping::new(), content.to_string(), false); + } + + let mut parts = content.splitn(3, "---"); + parts.next(); // empty before first --- + let yaml = parts.next().unwrap_or(""); + let body = parts.next().unwrap_or(""); + + let fm: Value = serde_yaml::from_str(yaml).unwrap_or(Value::Null); + let map = fm.as_mapping().cloned().unwrap_or_default(); + + (map, body.to_string(), true) +} + +/// Serialize frontmatter and body back to markdown string. +pub fn serialize_frontmatter(frontmatter: &Mapping, body: &str) -> Result { + let yaml = serde_yaml::to_string(frontmatter).map_err(|e| e.to_string())?; + + let mut content = String::new(); + content.push_str("---\n"); + content.push_str(&yaml); + content.push_str("---\n\n"); + content.push_str(body.trim_start()); + + Ok(content) +} + +/// Generate initial frontmatter for a newly created file. +/// Sets backend-owned fields only. +pub fn generate_frontmatter(path: &Path, note_type: &str) -> Mapping { + let mut map = Mapping::new(); + + let id = derive_id_from_path(path); + let now = Utc::now().to_rfc3339(); + + map.insert(Value::from("id"), Value::from(id)); + map.insert(Value::from("type"), Value::from(note_type)); + map.insert(Value::from("created"), Value::from(now.clone())); + map.insert(Value::from("updated"), Value::from(now)); + + map +} + +/// Ensure frontmatter has all required backend-owned fields. +/// - If `id` is missing, derive from path +/// - If `created` is missing, set to now +/// - Always updates `updated` timestamp +/// - Preserves all user-defined fields (title, tags, status, etc.) +pub fn ensure_frontmatter(existing: &mut Mapping, path: &Path) { + let now = Utc::now().to_rfc3339(); + + // Ensure ID exists (derive from path if missing) + if !existing.contains_key(&Value::from("id")) { + let id = derive_id_from_path(path); + existing.insert(Value::from("id"), Value::from(id)); + } + + // Ensure created timestamp exists (set once, never overwritten) + if !existing.contains_key(&Value::from("created")) { + existing.insert(Value::from("created"), Value::from(now.clone())); + } + + // Always update the updated timestamp + existing.insert(Value::from("updated"), Value::from(now)); +} + +/// Update frontmatter on save. +/// Only updates `updated` timestamp, preserves all other fields. +pub fn update_frontmatter(existing: &mut Mapping) { + let now = Utc::now().to_rfc3339(); + existing.insert(Value::from("updated"), Value::from(now)); +} + +/// Check if frontmatter has all required backend-owned fields. +pub fn is_frontmatter_complete(frontmatter: &Mapping) -> bool { + frontmatter.contains_key(&Value::from("id")) + && frontmatter.contains_key(&Value::from("created")) + && frontmatter.contains_key(&Value::from("updated")) +} + +// ============ Helper functions for cleaner frontmatter field access ============ + +/// Get a string value from frontmatter by key. +pub fn get_str(fm: &Mapping, key: &str) -> Option { + fm.get(&Value::from(key)) + .and_then(|v| v.as_str()) + .map(String::from) +} + +/// Get a string value from frontmatter, with a default fallback. +pub fn get_str_or(fm: &Mapping, key: &str, default: &str) -> String { + get_str(fm, key).unwrap_or_else(|| default.to_string()) +} + +/// Get a bool value from frontmatter by key. +pub fn get_bool(fm: &Mapping, key: &str) -> Option { + fm.get(&Value::from(key)).and_then(|v| v.as_bool()) +} + +/// Get a bool value from frontmatter, with a default fallback. +pub fn get_bool_or(fm: &Mapping, key: &str, default: bool) -> bool { + get_bool(fm, key).unwrap_or(default) +} + +/// Get a u64 value from frontmatter by key. +pub fn get_u64(fm: &Mapping, key: &str) -> Option { + fm.get(&Value::from(key)).and_then(|v| v.as_u64()) +} + +/// Get a string sequence (tags, etc.) from frontmatter by key. +pub fn get_string_seq(fm: &Mapping, key: &str) -> Vec { + fm.get(&Value::from(key)) + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_frontmatter_with_frontmatter() { + let content = "---\nid: test\ntitle: Test Note\n---\n\nBody content"; + let (fm, body, has_fm) = parse_frontmatter(content); + + assert!(has_fm); + assert_eq!(fm.get(&Value::from("id")).unwrap().as_str().unwrap(), "test"); + assert_eq!(fm.get(&Value::from("title")).unwrap().as_str().unwrap(), "Test Note"); + assert!(body.contains("Body content")); + } + + #[test] + fn test_parse_frontmatter_without_frontmatter() { + let content = "Just some content without frontmatter"; + let (fm, body, has_fm) = parse_frontmatter(content); + + assert!(!has_fm); + assert!(fm.is_empty()); + assert_eq!(body, content); + } + + #[test] + fn test_derive_id_from_path() { + let path = Path::new("data/notes/my-note.md"); + assert_eq!(derive_id_from_path(path), "my-note"); + + let path = Path::new("data/projects/myproject/index.md"); + assert_eq!(derive_id_from_path(path), "myproject-index"); + } +} diff --git a/backend/src/services/git.rs b/backend/src/services/git.rs new file mode 100644 index 0000000..ba5c158 --- /dev/null +++ b/backend/src/services/git.rs @@ -0,0 +1,655 @@ +use std::time::Duration; + +use chrono::Utc; +use git2::{Repository, Signature, StatusOptions}; +use serde::Serialize; +use tokio::time::interval; + +use crate::config; + +/// Git status for a file +#[derive(Debug, Clone, Serialize)] +pub struct FileStatus { + pub path: String, + pub status: String, // "new", "modified", "deleted", "renamed", "untracked" +} + +/// Overall repository status +#[derive(Debug, Serialize)] +pub struct RepoStatus { + pub is_repo: bool, + pub branch: Option, + pub files: Vec, + pub has_changes: bool, + pub last_commit: Option, +} + +/// Commit information +#[derive(Debug, Clone, Serialize)] +pub struct CommitInfo { + pub id: String, + pub message: String, + pub timestamp: String, +} + +/// Extended commit info for history +#[derive(Debug, Serialize)] +pub struct CommitDetail { + pub id: String, + pub short_id: String, + pub message: String, + pub author: String, + pub timestamp: String, + pub files_changed: usize, +} + +/// Diff information +#[derive(Debug, Serialize)] +pub struct DiffInfo { + pub files: Vec, + pub stats: DiffStats, +} + +/// File diff +#[derive(Debug, Serialize)] +pub struct FileDiff { + pub path: String, + pub status: String, + pub additions: usize, + pub deletions: usize, + pub hunks: Vec, +} + +/// Diff hunk (section of changes) +#[derive(Debug, Serialize)] +pub struct DiffHunk { + pub header: String, + pub lines: Vec, +} + +/// Single diff line +#[derive(Debug, Serialize)] +pub struct DiffLine { + pub origin: char, + pub content: String, +} + +/// Diff statistics +#[derive(Debug, Serialize)] +pub struct DiffStats { + pub files_changed: usize, + pub insertions: usize, + pub deletions: usize, +} + +/// Remote repository information +#[derive(Debug, Serialize)] +pub struct RemoteInfo { + pub name: String, + pub url: String, + pub has_upstream: bool, + pub ahead: usize, + pub behind: usize, +} + +/// Auto-commit is enabled by default. +/// The background task simply tries to commit every interval; +/// commit_all() already handles "no changes" gracefully. + +/// Get repository status +pub fn get_status() -> Result { + let data_path = config::data_dir(); + + // Try to open as git repo + let repo = match Repository::open(data_path) { + Ok(r) => r, + Err(_) => { + return Ok(RepoStatus { + is_repo: false, + branch: None, + files: Vec::new(), + has_changes: false, + last_commit: None, + }); + } + }; + + // Get current branch + let branch = repo + .head() + .ok() + .and_then(|h| h.shorthand().map(String::from)); + + // Get file statuses + let mut opts = StatusOptions::new(); + opts.include_untracked(true) + .recurse_untracked_dirs(true) + .exclude_submodules(true); + + let statuses = repo.statuses(Some(&mut opts)).map_err(|e| e.to_string())?; + + let files: Vec = statuses + .iter() + .filter_map(|entry| { + let path = entry.path()?.to_string(); + let status = entry.status(); + + let status_str = if status.is_index_new() || status.is_wt_new() { + "new" + } else if status.is_index_modified() || status.is_wt_modified() { + "modified" + } else if status.is_index_deleted() || status.is_wt_deleted() { + "deleted" + } else if status.is_index_renamed() || status.is_wt_renamed() { + "renamed" + } else { + return None; + }; + + Some(FileStatus { + path, + status: status_str.to_string(), + }) + }) + .collect(); + + let has_changes = !files.is_empty(); + + // Get last commit info + let last_commit = repo.head().ok().and_then(|head| { + let commit = head.peel_to_commit().ok()?; + Some(CommitInfo { + id: commit.id().to_string()[..8].to_string(), + message: commit.message()?.trim().to_string(), + timestamp: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)? + .to_rfc3339(), + }) + }); + + Ok(RepoStatus { + is_repo: true, + branch, + files, + has_changes, + last_commit, + }) +} + +/// Create a commit with all changes +pub fn commit_all(message: Option<&str>) -> Result { + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + // Stage all changes + let mut index = repo.index().map_err(|e| e.to_string())?; + index + .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) + .map_err(|e| e.to_string())?; + index.write().map_err(|e| e.to_string())?; + + // Check if there are changes to commit + let tree_id = index.write_tree().map_err(|e| e.to_string())?; + let tree = repo.find_tree(tree_id).map_err(|e| e.to_string())?; + + // Get parent commit (if any) + let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok()); + + // Check if tree is different from parent + if let Some(ref p) = parent { + if p.tree().map(|t| t.id()) == Ok(tree_id) { + return Err("No changes to commit".to_string()); + } + } + + // Create signature + let sig = Signature::now("Ironpad", "ironpad@local").map_err(|e| e.to_string())?; + + // Generate commit message + let msg = message.unwrap_or_else(|| "Auto-save"); + let timestamp = Utc::now().format("%Y-%m-%d %H:%M"); + let full_message = format!("{} ({})", msg, timestamp); + + // Create commit + let parents: Vec<&git2::Commit> = parent.as_ref().map(|p| vec![p]).unwrap_or_default(); + let commit_id = repo + .commit(Some("HEAD"), &sig, &sig, &full_message, &tree, &parents) + .map_err(|e| e.to_string())?; + + Ok(CommitInfo { + id: commit_id.to_string()[..8].to_string(), + message: full_message, + timestamp: Utc::now().to_rfc3339(), + }) +} + +/// Initialize data directory as a git repository if not already +pub fn init_repo() -> Result<(), String> { + let data_path = config::data_dir(); + + if Repository::open(data_path).is_ok() { + return Ok(()); // Already a repo + } + + Repository::init(data_path).map_err(|e| format!("Failed to init repo: {}", e))?; + + // Create initial .gitignore + let gitignore_path = data_path.join(".gitignore"); + if !gitignore_path.exists() { + std::fs::write(&gitignore_path, "*.tmp\n.DS_Store\n") + .map_err(|e| format!("Failed to create .gitignore: {}", e))?; + } + + // Initial commit + commit_all(Some("Initial commit"))?; + + Ok(()) +} + +/// Check for merge conflicts +pub fn check_conflicts() -> Result, String> { + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + let mut conflicts = Vec::new(); + + // Check for .git/index.lock (another git operation in progress) + let lock_path = data_path.join(".git").join("index.lock"); + if lock_path.exists() { + // This isn't a conflict per se, but indicates git is busy + tracing::warn!("Git index.lock exists - another operation may be in progress"); + } + + // Check status for conflicted files + let mut opts = StatusOptions::new(); + opts.include_untracked(false); + + let statuses = repo.statuses(Some(&mut opts)).map_err(|e| e.to_string())?; + + for entry in statuses.iter() { + let status = entry.status(); + // Check for conflict status flags + if status.is_conflicted() { + if let Some(path) = entry.path() { + conflicts.push(path.to_string()); + } + } + } + + // Also check the index for conflicts + let index = repo.index().map_err(|e| e.to_string())?; + if index.has_conflicts() { + for conflict in index.conflicts().map_err(|e| e.to_string())? { + if let Ok(conflict) = conflict { + if let Some(ancestor) = conflict.ancestor { + if let Some(path) = std::str::from_utf8(&ancestor.path).ok() { + if !conflicts.contains(&path.to_string()) { + conflicts.push(path.to_string()); + } + } + } + } + } + } + + Ok(conflicts) +} + +/// Push to remote repository +pub fn push_to_remote() -> Result<(), String> { + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + // Get the current branch + let head = repo.head().map_err(|e| e.to_string())?; + let branch_name = head + .shorthand() + .ok_or_else(|| "Could not get branch name".to_string())?; + + // Find the remote (default to "origin") + let mut remote = repo + .find_remote("origin") + .map_err(|e| format!("Remote 'origin' not found: {}", e))?; + + // Check if remote URL is configured + let remote_url = remote.url().ok_or_else(|| "No remote URL configured".to_string())?; + if remote_url.is_empty() { + return Err("No remote URL configured".to_string()); + } + + // Create callbacks for authentication + let mut callbacks = git2::RemoteCallbacks::new(); + + // Try to use credential helper from git config + callbacks.credentials(|_url, username_from_url, _allowed_types| { + // Try SSH agent first + git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) + }); + + // Set up push options + let mut push_options = git2::PushOptions::new(); + push_options.remote_callbacks(callbacks); + + // Push the current branch + let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name); + remote + .push(&[&refspec], Some(&mut push_options)) + .map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?; + + tracing::info!("Successfully pushed to origin/{}", branch_name); + Ok(()) +} + +/// Check if remote is configured +pub fn has_remote() -> bool { + let data_path = config::data_dir(); + if let Ok(repo) = Repository::open(data_path) { + if let Ok(remote) = repo.find_remote("origin") { + return remote.url().is_some(); + } + } + false +} + +/// Start auto-commit background task. +/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully. +pub fn start_auto_commit() { + tokio::spawn(async move { + let mut interval = interval(Duration::from_secs(60)); + + loop { + interval.tick().await; + + match commit_all(Some("Auto-save")) { + Ok(info) => { + tracing::info!("Auto-commit: {} - {}", info.id, info.message); + } + Err(e) => { + if !e.contains("No changes") { + tracing::warn!("Auto-commit failed: {}", e); + } + } + } + } + }); +} + +/// Get commit history (most recent first) +pub fn get_log(limit: Option) -> Result, String> { + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; + revwalk.push_head().map_err(|e| e.to_string())?; + revwalk + .set_sorting(git2::Sort::TIME) + .map_err(|e| e.to_string())?; + + let max_commits = limit.unwrap_or(50); + let mut commits = Vec::new(); + + for (i, oid_result) in revwalk.enumerate() { + if i >= max_commits { + break; + } + + let oid = oid_result.map_err(|e| e.to_string())?; + let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; + + // Count files changed in this commit + let files_changed = if commit.parent_count() > 0 { + let parent = commit.parent(0).ok(); + let parent_tree = parent.as_ref().and_then(|p| p.tree().ok()); + let commit_tree = commit.tree().ok(); + + if let (Some(pt), Some(ct)) = (parent_tree, commit_tree) { + let diff = repo + .diff_tree_to_tree(Some(&pt), Some(&ct), None) + .ok(); + diff.map(|d| d.deltas().count()).unwrap_or(0) + } else { + 0 + } + } else { + // Initial commit - count all files + commit + .tree() + .ok() + .map(|t| count_tree_entries(&t)) + .unwrap_or(0) + }; + + let timestamp = + chrono::DateTime::from_timestamp(commit.time().seconds(), 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| "Unknown".to_string()); + + commits.push(CommitDetail { + id: oid.to_string(), + short_id: oid.to_string()[..8].to_string(), + message: commit.message().unwrap_or("").trim().to_string(), + author: commit.author().name().unwrap_or("Unknown").to_string(), + timestamp, + files_changed, + }); + } + + Ok(commits) +} + +/// Helper to count entries in a tree recursively +fn count_tree_entries(tree: &git2::Tree) -> usize { + tree.iter() + .filter(|entry| entry.kind() == Some(git2::ObjectType::Blob)) + .count() +} + +/// Get working directory diff (uncommitted changes) +pub fn get_working_diff() -> Result { + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + // Get HEAD tree (or empty tree if no commits) + let head_tree = repo + .head() + .ok() + .and_then(|h| h.peel_to_tree().ok()); + + // Diff against working directory + let diff = repo + .diff_tree_to_workdir_with_index(head_tree.as_ref(), None) + .map_err(|e| e.to_string())?; + + parse_diff(&diff) +} + +/// Get diff for a specific commit +pub fn get_commit_diff(commit_id: &str) -> Result { + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + let oid = git2::Oid::from_str(commit_id).map_err(|e| format!("Invalid commit ID: {}", e))?; + let commit = repo + .find_commit(oid) + .map_err(|e| format!("Commit not found: {}", e))?; + + let commit_tree = commit.tree().map_err(|e| e.to_string())?; + + let parent_tree = if commit.parent_count() > 0 { + commit.parent(0).ok().and_then(|p| p.tree().ok()) + } else { + None + }; + + let diff = repo + .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None) + .map_err(|e| e.to_string())?; + + parse_diff(&diff) +} + +/// Parse a git2::Diff into our DiffInfo structure +fn parse_diff(diff: &git2::Diff) -> Result { + let stats = diff.stats().map_err(|e| e.to_string())?; + + let mut files = Vec::new(); + + for delta_idx in 0..diff.deltas().count() { + let delta = diff.get_delta(delta_idx).ok_or("Missing delta")?; + + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + + let status = match delta.status() { + git2::Delta::Added => "added", + git2::Delta::Deleted => "deleted", + git2::Delta::Modified => "modified", + git2::Delta::Renamed => "renamed", + git2::Delta::Copied => "copied", + _ => "unknown", + }; + + let mut hunks = Vec::new(); + let mut additions = 0; + let mut deletions = 0; + + // Get patch for this file + if let Ok(patch) = git2::Patch::from_diff(diff, delta_idx) { + if let Some(p) = patch { + for hunk_idx in 0..p.num_hunks() { + if let Ok((hunk, _)) = p.hunk(hunk_idx) { + let mut lines = Vec::new(); + + for line_idx in 0..p.num_lines_in_hunk(hunk_idx).unwrap_or(0) { + if let Ok(line) = p.line_in_hunk(hunk_idx, line_idx) { + let origin = line.origin(); + let content = std::str::from_utf8(line.content()) + .unwrap_or("") + .to_string(); + + match origin { + '+' => additions += 1, + '-' => deletions += 1, + _ => {} + } + + lines.push(DiffLine { origin, content }); + } + } + + hunks.push(DiffHunk { + header: std::str::from_utf8(hunk.header()) + .unwrap_or("") + .trim() + .to_string(), + lines, + }); + } + } + } + } + + files.push(FileDiff { + path, + status: status.to_string(), + additions, + deletions, + hunks, + }); + } + + Ok(DiffInfo { + files, + stats: DiffStats { + files_changed: stats.files_changed(), + insertions: stats.insertions(), + deletions: stats.deletions(), + }, + }) +} + +/// Get remote repository information +pub fn get_remote_info() -> Result, String> { + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + let remote = match repo.find_remote("origin") { + Ok(r) => r, + Err(_) => return Ok(None), + }; + + let url = remote.url().unwrap_or("").to_string(); + if url.is_empty() { + return Ok(None); + } + + // Get current branch + let head = match repo.head() { + Ok(h) => h, + Err(_) => { + return Ok(Some(RemoteInfo { + name: "origin".to_string(), + url, + has_upstream: false, + ahead: 0, + behind: 0, + })); + } + }; + + let branch_name = head.shorthand().unwrap_or("HEAD"); + + // Try to find upstream branch + let local_branch = repo.find_branch(branch_name, git2::BranchType::Local).ok(); + let upstream = local_branch.as_ref().and_then(|b| b.upstream().ok()); + + let (ahead, behind) = if let Some(ref up) = upstream { + // Calculate ahead/behind + let local_oid = head.target().unwrap_or_else(git2::Oid::zero); + let upstream_oid = up + .get() + .target() + .unwrap_or_else(git2::Oid::zero); + + repo.graph_ahead_behind(local_oid, upstream_oid) + .unwrap_or((0, 0)) + } else { + (0, 0) + }; + + Ok(Some(RemoteInfo { + name: "origin".to_string(), + url, + has_upstream: upstream.is_some(), + ahead, + behind, + })) +} + +/// Fetch from remote +pub fn fetch_from_remote() -> Result<(), String> { + let data_path = config::data_dir(); + let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?; + + let mut remote = repo + .find_remote("origin") + .map_err(|e| format!("Remote 'origin' not found: {}", e))?; + + // Create callbacks for authentication + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, _allowed_types| { + git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) + }); + + let mut fetch_options = git2::FetchOptions::new(); + fetch_options.remote_callbacks(callbacks); + + remote + .fetch(&[] as &[&str], Some(&mut fetch_options), None) + .map_err(|e| format!("Fetch failed: {}", e))?; + + Ok(()) +} diff --git a/backend/src/services/locks.rs b/backend/src/services/locks.rs new file mode 100644 index 0000000..e6c029a --- /dev/null +++ b/backend/src/services/locks.rs @@ -0,0 +1,149 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +/// Type of lock held on a file +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LockType { + Editor, + TaskView, +} + +/// Information about a file lock +#[derive(Debug, Clone, Serialize)] +pub struct LockInfo { + pub path: String, + pub client_id: String, + pub lock_type: LockType, + pub acquired_at: DateTime, +} + +/// Error type for lock operations +#[derive(Debug, Clone, Serialize)] +pub enum LockError { + AlreadyLocked { holder: String, lock_type: LockType }, + NotLocked, + NotOwner, +} + +impl std::fmt::Display for LockError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LockError::AlreadyLocked { holder, lock_type } => { + write!(f, "File already locked by {} ({:?})", holder, lock_type) + } + LockError::NotLocked => write!(f, "File is not locked"), + LockError::NotOwner => write!(f, "You do not own this lock"), + } + } +} + +/// Manages file locks across the application +#[derive(Debug, Clone)] +pub struct FileLockManager { + locks: Arc>>, +} + +impl FileLockManager { + pub fn new() -> Self { + Self { + locks: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Attempt to acquire a lock on a file + pub async fn acquire( + &self, + path: &str, + client_id: &str, + lock_type: LockType, + ) -> Result { + let mut locks = self.locks.write().await; + + // Check if already locked + if let Some(existing) = locks.get(path) { + if existing.client_id != client_id { + return Err(LockError::AlreadyLocked { + holder: existing.client_id.clone(), + lock_type: existing.lock_type, + }); + } + // Same client - update lock type + } + + let lock_info = LockInfo { + path: path.to_string(), + client_id: client_id.to_string(), + lock_type, + acquired_at: Utc::now(), + }; + + locks.insert(path.to_string(), lock_info.clone()); + Ok(lock_info) + } + + /// Release a lock on a file + pub async fn release(&self, path: &str, client_id: &str) -> Result<(), LockError> { + let mut locks = self.locks.write().await; + + if let Some(existing) = locks.get(path) { + if existing.client_id != client_id { + return Err(LockError::NotOwner); + } + locks.remove(path); + Ok(()) + } else { + Err(LockError::NotLocked) + } + } + + /// Check if a file is locked + pub async fn is_locked(&self, path: &str) -> Option { + let locks = self.locks.read().await; + locks.get(path).cloned() + } + + /// Check if a file is locked by someone other than the given client + pub async fn is_locked_by_other(&self, path: &str, client_id: &str) -> Option { + let locks = self.locks.read().await; + locks.get(path).and_then(|lock| { + if lock.client_id != client_id { + Some(lock.clone()) + } else { + None + } + }) + } + + /// Release all locks held by a client (used on disconnect) + pub async fn release_all_for_client(&self, client_id: &str) -> Vec { + let mut locks = self.locks.write().await; + let paths_to_remove: Vec = locks + .iter() + .filter(|(_, lock)| lock.client_id == client_id) + .map(|(path, _)| path.clone()) + .collect(); + + for path in &paths_to_remove { + locks.remove(path); + } + + paths_to_remove + } + + /// Get all current locks (for debugging/monitoring) + pub async fn get_all_locks(&self) -> Vec { + let locks = self.locks.read().await; + locks.values().cloned().collect() + } +} + +impl Default for FileLockManager { + fn default() -> Self { + Self::new() + } +} diff --git a/backend/src/services/markdown.rs b/backend/src/services/markdown.rs new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..d3d0ca8 --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,6 @@ +pub mod filesystem; +pub mod frontmatter; +pub mod git; +pub mod locks; +pub mod markdown; +pub mod search; \ No newline at end of file diff --git a/backend/src/services/search.rs b/backend/src/services/search.rs new file mode 100644 index 0000000..23b4f8d --- /dev/null +++ b/backend/src/services/search.rs @@ -0,0 +1,188 @@ +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde::Serialize; +use walkdir::WalkDir; + +use crate::config; + +/// Search result item +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub path: String, + pub title: String, + pub matches: Vec, +} + +/// Individual match within a file +#[derive(Debug, Serialize)] +pub struct SearchMatch { + pub line_number: u32, + pub line_content: String, +} + +/// Search notes using simple string matching +/// Falls back to manual search if ripgrep is not available +pub fn search_notes(query: &str) -> Result, String> { + if query.trim().is_empty() { + return Ok(Vec::new()); + } + + // Try ripgrep first (faster) + match search_with_ripgrep(query) { + Ok(results) => return Ok(results), + Err(e) => { + tracing::debug!("ripgrep not available, falling back to manual search: {}", e); + } + } + + // Fallback to manual search + search_manual(query) +} + +/// Search using ripgrep (rg) +fn search_with_ripgrep(query: &str) -> Result, String> { + let data_dir_str = config::data_dir().to_string_lossy(); + let output = Command::new("rg") + .args([ + "--json", // JSON output for parsing + "--ignore-case", // Case insensitive + "--type", "md", // Only markdown files + "--max-count", "5", // Max 5 matches per file + query, + &data_dir_str, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .map_err(|e| format!("Failed to run ripgrep: {}", e))?; + + if !output.status.success() && output.stdout.is_empty() { + // No matches found or error + return Ok(Vec::new()); + } + + parse_ripgrep_output(&output.stdout) +} + +/// Parse ripgrep JSON output +fn parse_ripgrep_output(output: &[u8]) -> Result, String> { + use std::collections::HashMap; + + let output_str = String::from_utf8_lossy(output); + let mut results_map: HashMap = HashMap::new(); + + for line in output_str.lines() { + if let Ok(json) = serde_json::from_str::(line) { + if json["type"] == "match" { + let data = &json["data"]; + let path_str = data["path"]["text"].as_str().unwrap_or(""); + let line_number = data["line_number"].as_u64().unwrap_or(0) as u32; + let line_content = data["lines"]["text"] + .as_str() + .unwrap_or("") + .trim() + .to_string(); + + let normalized_path = normalize_path(path_str); + let title = extract_title_from_path(&normalized_path); + + let result = results_map.entry(normalized_path.clone()).or_insert_with(|| { + SearchResult { + path: normalized_path, + title, + matches: Vec::new(), + } + }); + + result.matches.push(SearchMatch { + line_number, + line_content, + }); + } + } + } + + Ok(results_map.into_values().collect()) +} + +/// Manual search fallback (no external dependencies) +fn search_manual(query: &str) -> Result, String> { + let query_lower = query.to_lowercase(); + let root = config::data_dir(); + let mut results = Vec::new(); + + for entry in WalkDir::new(root) + .into_iter() + .filter_entry(|e| !is_ignored(e.path())) + .filter_map(Result::ok) + { + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) != Some("md") { + continue; + } + + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + + let mut matches = Vec::new(); + for (i, line) in content.lines().enumerate() { + if line.to_lowercase().contains(&query_lower) { + matches.push(SearchMatch { + line_number: (i + 1) as u32, + line_content: line.trim().to_string(), + }); + + // Limit matches per file + if matches.len() >= 5 { + break; + } + } + } + + if !matches.is_empty() { + let normalized_path = normalize_path(&path.to_string_lossy()); + let title = extract_title_from_path(&normalized_path); + + results.push(SearchResult { + path: normalized_path, + title, + matches, + }); + } + } + + Ok(results) +} + +fn is_ignored(path: &Path) -> bool { + path.components().any(|c| { + matches!( + c.as_os_str().to_str(), + Some(".git") | Some("assets") | Some("archive") + ) + }) +} + +fn normalize_path(path: &str) -> String { + if let Some(idx) = path.find("data") { + let stripped = &path[idx + 5..]; + return stripped + .replace('\\', "/") + .trim_start_matches('/') + .to_string(); + } + path.replace('\\', "/") +} + +fn extract_title_from_path(path: &str) -> String { + Path::new(path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Untitled") + .to_string() +} diff --git a/backend/src/watcher.rs b/backend/src/watcher.rs new file mode 100644 index 0000000..28a57ec --- /dev/null +++ b/backend/src/watcher.rs @@ -0,0 +1,161 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use notify::{RecursiveMode, Watcher}; +use notify_debouncer_full::{new_debouncer, DebouncedEvent}; +use tokio::sync::mpsc; + +use crate::config; +use crate::websocket::{WsMessage, WsState}; + +/// Start the file watcher in a background task +pub async fn start_watcher(ws_state: Arc) -> Result<(), String> { + let (tx, mut rx) = mpsc::channel::>(100); + + // Create debouncer with 500ms debounce time + let debouncer = new_debouncer( + Duration::from_millis(500), + None, + move |result: Result, Vec>| { + if let Ok(events) = result { + let _ = tx.blocking_send(events); + } + }, + ) + .map_err(|e| format!("Failed to create file watcher: {}", e))?; + + // Watch the data directory + let data_path = config::data_dir(); + if !data_path.exists() { + return Err(format!("Data directory does not exist: {}", data_path.display())); + } + + // We need to keep the debouncer alive, so we'll store it + let debouncer = Arc::new(tokio::sync::Mutex::new(debouncer)); + + { + let mut d = debouncer.lock().await; + d.watcher().watch(data_path, RecursiveMode::Recursive) + .map_err(|e| format!("Failed to watch directory: {}", e))?; + } + + tracing::info!("File watcher started for: {}", data_path.display()); + + // Spawn task to process file events + let ws_state_clone = ws_state.clone(); + tokio::spawn(async move { + // Keep debouncer alive + let _debouncer = debouncer; + + while let Some(events) = rx.recv().await { + for event in events { + process_event(&event, &ws_state_clone); + } + } + }); + + Ok(()) +} + +/// Track recent saves to avoid notifying about our own changes +use std::sync::Mutex; +use std::collections::HashMap; +use std::time::Instant; + +lazy_static::lazy_static! { + static ref RECENT_SAVES: Mutex> = Mutex::new(HashMap::new()); +} + +/// Mark a file as recently saved by us (call this before saving) +pub fn mark_file_saved(path: &str) { + if let Ok(mut saves) = RECENT_SAVES.lock() { + saves.insert(path.to_string(), Instant::now()); + } +} + +/// Process a single debounced file event +fn process_event(event: &DebouncedEvent, ws_state: &WsState) { + use notify::EventKind; + + // Only process markdown files + let paths: Vec<_> = event + .paths + .iter() + .filter(|p| { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| e == "md") + .unwrap_or(false) + }) + .collect(); + + if paths.is_empty() { + return; + } + + // Skip temporary files (used for atomic writes) + if paths.iter().any(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.') && n.ends_with(".tmp")) + .unwrap_or(false) + }) { + return; + } + + // Skip archive and .git directories + if paths.iter().any(|p| { + let s = p.to_string_lossy(); + s.contains("archive") || s.contains(".git") + }) { + return; + } + + let path_str = normalize_path(&paths[0]); + + // Check if this was a recent save by us (within last 2 seconds) + if let Ok(mut saves) = RECENT_SAVES.lock() { + // Clean up old entries + saves.retain(|_, t| t.elapsed().as_secs() < 5); + + if let Some(saved_at) = saves.get(&path_str) { + if saved_at.elapsed().as_secs() < 2 { + return; // Skip - this was our own save + } + } + } + + let msg = match &event.kind { + EventKind::Create(_) => { + tracing::info!("External file created: {}", path_str); + Some(WsMessage::FileCreated { path: path_str }) + } + EventKind::Modify(_) => { + tracing::info!("External file modified: {}", path_str); + Some(WsMessage::FileModified { path: path_str }) + } + EventKind::Remove(_) => { + tracing::info!("External file deleted: {}", path_str); + Some(WsMessage::FileDeleted { path: path_str }) + } + _ => None, + }; + + if let Some(msg) = msg { + ws_state.broadcast(msg); + } +} + +/// Normalize path for client consumption +fn normalize_path(path: &Path) -> String { + let path_str = path.to_string_lossy(); + + // Find "data" in the path and strip everything before and including it + if let Some(idx) = path_str.find("data") { + let stripped = &path_str[idx + 5..]; // Skip "data" + separator + return stripped.replace('\\', "/").trim_start_matches('/').to_string(); + } + + path_str.replace('\\', "/") +} diff --git a/backend/src/websocket.rs b/backend/src/websocket.rs new file mode 100644 index 0000000..1e7c8bb --- /dev/null +++ b/backend/src/websocket.rs @@ -0,0 +1,230 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use axum::{ + extract::{ + ws::{Message, WebSocket}, + State, WebSocketUpgrade, + }, + response::IntoResponse, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{broadcast, RwLock}; + +use crate::services::locks::{FileLockManager, LockType}; + +/// WebSocket message types sent to clients +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "payload")] +pub enum WsMessage { + /// A file was created + FileCreated { path: String }, + /// A file was modified + FileModified { path: String }, + /// A file was deleted + FileDeleted { path: String }, + /// A file was renamed + FileRenamed { from: String, to: String }, + /// A file was locked + FileLocked { + path: String, + client_id: String, + lock_type: String, + }, + /// A file was unlocked + FileUnlocked { path: String }, + /// Git conflict detected + GitConflict { files: Vec }, + /// Server is sending a ping + Ping, + /// Client connection confirmed + Connected { client_id: String }, + /// Error message + Error { message: String }, +} + +/// Client message types received from clients +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum ClientMessage { + /// Request to lock a file + #[serde(rename = "lock_file")] + LockFile { path: String, lock_type: String }, + /// Request to unlock a file + #[serde(rename = "unlock_file")] + UnlockFile { path: String }, + /// Ping response + #[serde(rename = "pong")] + Pong, +} + +/// Shared state for WebSocket connections +#[derive(Debug, Clone)] +pub struct WsState { + /// Broadcast channel for sending messages to all clients + pub tx: broadcast::Sender, + /// Set of connected client IDs + pub clients: Arc>>, + /// File lock manager + pub lock_manager: FileLockManager, +} + +impl WsState { + pub fn new() -> Self { + let (tx, _) = broadcast::channel(100); + Self { + tx, + clients: Arc::new(RwLock::new(HashSet::new())), + lock_manager: FileLockManager::new(), + } + } + + /// Broadcast a message to all connected clients + pub fn broadcast(&self, msg: WsMessage) { + // Ignore send errors (no receivers) + let _ = self.tx.send(msg); + } +} + +impl Default for WsState { + fn default() -> Self { + Self::new() + } +} + +/// WebSocket upgrade handler +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +/// Handle individual WebSocket connection +async fn handle_socket(socket: WebSocket, state: Arc) { + let client_id = uuid::Uuid::new_v4().to_string(); + + // Add client to set + { + let mut clients = state.clients.write().await; + clients.insert(client_id.clone()); + } + + tracing::info!("WebSocket client connected: {}", client_id); + + let (mut sender, mut receiver) = socket.split(); + + // Subscribe to broadcast channel + let mut rx = state.tx.subscribe(); + + // Send connected message + let connected_msg = WsMessage::Connected { + client_id: client_id.clone(), + }; + if let Ok(json) = serde_json::to_string(&connected_msg) { + let _ = sender.send(Message::Text(json.into())).await; + } + + // Spawn task to forward broadcast messages to this client + let send_task = tokio::spawn(async move { + while let Ok(msg) = rx.recv().await { + if let Ok(json) = serde_json::to_string(&msg) { + if sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + } + }); + + // Handle incoming messages from client + let state_clone = state.clone(); + let client_id_clone = client_id.clone(); + let recv_task = tokio::spawn(async move { + while let Some(Ok(msg)) = receiver.next().await { + match msg { + Message::Text(text) => { + if let Ok(client_msg) = serde_json::from_str::(&text) { + handle_client_message(&state_clone, &client_id_clone, client_msg).await; + } else { + tracing::debug!( + "Unknown message from {}: {}", + client_id_clone, + text + ); + } + } + Message::Close(_) => break, + _ => {} + } + } + }); + + // Wait for either task to complete + tokio::select! { + _ = send_task => {}, + _ = recv_task => {}, + } + + // Clean up on disconnect + // Release all locks held by this client + let released_paths = state.lock_manager.release_all_for_client(&client_id).await; + for path in released_paths { + state.broadcast(WsMessage::FileUnlocked { path }); + } + + // Remove client from set + { + let mut clients = state.clients.write().await; + clients.remove(&client_id); + } + + tracing::info!("WebSocket client disconnected: {}", client_id); +} + +/// Handle a message from a client +async fn handle_client_message(state: &Arc, client_id: &str, msg: ClientMessage) { + match msg { + ClientMessage::LockFile { path, lock_type } => { + let lock_type = match lock_type.as_str() { + "editor" => LockType::Editor, + "task_view" => LockType::TaskView, + _ => { + tracing::warn!("Unknown lock type: {}", lock_type); + return; + } + }; + + match state.lock_manager.acquire(&path, client_id, lock_type).await { + Ok(lock_info) => { + let lock_type_str = match lock_info.lock_type { + LockType::Editor => "editor", + LockType::TaskView => "task_view", + }; + state.broadcast(WsMessage::FileLocked { + path: lock_info.path, + client_id: lock_info.client_id, + lock_type: lock_type_str.to_string(), + }); + } + Err(e) => { + tracing::warn!("Failed to acquire lock: {}", e); + // Could send error back to specific client if needed + } + } + } + ClientMessage::UnlockFile { path } => { + match state.lock_manager.release(&path, client_id).await { + Ok(()) => { + state.broadcast(WsMessage::FileUnlocked { path }); + } + Err(e) => { + tracing::warn!("Failed to release lock: {}", e); + } + } + } + ClientMessage::Pong => { + // Heartbeat response, no action needed + } + } +} diff --git a/data/archive/.gitkeep b/data/archive/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/daily/.gitkeep b/data/daily/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/notes/.gitkeep b/data/notes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/notes/assets/.gitkeep b/data/notes/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/projects/.gitkeep b/data/projects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..1b37640 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,659 @@ +# Ironpad API Reference + +Base URL: `http://localhost:3000` + +## Notes + +### List Notes + +```http +GET /api/notes +``` + +**Response:** +```json +[ + { + "id": "20260205-123456", + "title": "My Note", + "path": "notes/20260205-123456.md", + "created": "2026-02-05T12:34:56Z", + "updated": "2026-02-05T12:34:56Z" + } +] +``` + +### Create Note + +```http +POST /api/notes +Content-Type: application/json + +{ + "title": "Optional Title", + "content": "# My Note\n\nContent here" +} +``` + +**Response:** `201 Created` +```json +{ + "id": "20260205-123456", + "title": "Optional Title", + "path": "notes/20260205-123456.md", + "content": "# My Note\n\nContent here", + "created": "2026-02-05T12:34:56Z", + "updated": "2026-02-05T12:34:56Z" +} +``` + +### Get Note + +```http +GET /api/notes/:id +``` + +**Response:** +```json +{ + "id": "20260205-123456", + "title": "My Note", + "path": "notes/20260205-123456.md", + "content": "# My Note\n\nFull content...", + "created": "2026-02-05T12:34:56Z", + "updated": "2026-02-05T12:34:56Z" +} +``` + +### Update Note + +```http +PUT /api/notes/:id +Content-Type: application/json + +{ + "content": "# Updated Content\n\nNew content here" +} +``` + +**Response:** +```json +{ + "id": "20260205-123456", + "title": "Updated Content", + "path": "notes/20260205-123456.md", + "content": "# Updated Content\n\nNew content here", + "created": "2026-02-05T12:34:56Z", + "updated": "2026-02-05T12:35:00Z" +} +``` + +### Delete (Archive) Note + +```http +DELETE /api/notes/:id +``` + +**Response:** `200 OK` + +Note: The note is moved to `archive/`, not permanently deleted. + +--- + +## Projects + +### List Projects + +```http +GET /api/projects +``` + +**Response:** +```json +[ + { + "id": "ferrite", + "title": "Ferrite", + "description": "A Rust project", + "path": "projects/ferrite", + "created": "2026-02-04T10:00:00Z", + "updated": "2026-02-05T12:00:00Z" + } +] +``` + +### Create Project + +```http +POST /api/projects +Content-Type: application/json + +{ + "title": "New Project", + "description": "Project description" +} +``` + +**Response:** `201 Created` +```json +{ + "id": "new-project", + "title": "New Project", + "description": "Project description", + "path": "projects/new-project", + "created": "2026-02-05T12:34:56Z", + "updated": "2026-02-05T12:34:56Z" +} +``` + +### Get Project + +```http +GET /api/projects/:id +``` + +**Response:** +```json +{ + "id": "ferrite", + "title": "Ferrite", + "description": "A Rust project", + "path": "projects/ferrite", + "created": "2026-02-04T10:00:00Z", + "updated": "2026-02-05T12:00:00Z" +} +``` + +### Get Project Content + +```http +GET /api/projects/:id/content +``` + +**Response:** +```json +{ + "content": "# Ferrite\n\nProject overview content..." +} +``` + +### Update Project Content + +```http +PUT /api/projects/:id/content +Content-Type: application/json + +{ + "content": "# Updated Overview\n\nNew content..." +} +``` + +--- + +## Project Notes + +### List Project Notes + +```http +GET /api/projects/:id/notes +``` + +**Response:** +```json +[ + { + "id": "20260205-123456", + "title": "Project Note", + "path": "projects/ferrite/notes/20260205-123456.md", + "created": "2026-02-05T12:34:56Z", + "updated": "2026-02-05T12:34:56Z" + } +] +``` + +### Create Project Note + +```http +POST /api/projects/:id/notes +Content-Type: application/json + +{ + "title": "New Note", + "content": "Note content..." +} +``` + +### Get Project Note + +```http +GET /api/projects/:id/notes/:noteId +``` + +### Update Project Note + +```http +PUT /api/projects/:id/notes/:noteId +Content-Type: application/json + +{ + "content": "Updated content..." +} +``` + +### Delete Project Note + +```http +DELETE /api/projects/:id/notes/:noteId +``` + +--- + +## Project Tasks + +### List Project Tasks + +```http +GET /api/projects/:id/tasks +``` + +**Response:** +```json +[ + { + "id": "task-20260205-123456", + "title": "Implement feature X", + "completed": false, + "section": "Active", + "priority": "high", + "due_date": "2026-02-10", + "is_active": true, + "content": "## Requirements\n\n- Item 1\n- Item 2", + "path": "projects/ferrite/tasks/task-20260205-123456.md", + "created": "2026-02-05T12:34:56Z", + "updated": "2026-02-05T12:34:56Z" + } +] +``` + +### Create Task + +```http +POST /api/projects/:id/tasks +Content-Type: application/json + +{ + "title": "New Task", + "content": "Task description..." +} +``` + +### Get Task + +```http +GET /api/projects/:id/tasks/:taskId +``` + +### Update Task Content + +```http +PUT /api/projects/:id/tasks/:taskId +Content-Type: application/json + +{ + "content": "Updated task description..." +} +``` + +### Update Task Metadata + +```http +PUT /api/projects/:id/tasks/:taskId/meta +Content-Type: application/json + +{ + "title": "New Title", + "is_active": false, + "section": "Backlog", + "priority": "low", + "due_date": "2026-02-15" +} +``` + +### Toggle Task Completion + +```http +PUT /api/projects/:id/tasks/:taskId/toggle +``` + +**Response:** +```json +{ + "completed": true +} +``` + +### Delete Task + +```http +DELETE /api/projects/:id/tasks/:taskId +``` + +--- + +## All Tasks + +### List All Tasks (across projects) + +```http +GET /api/tasks +``` + +Returns tasks from all projects, useful for global task views. + +--- + +## Daily Notes + +### List Daily Notes + +```http +GET /api/daily +``` + +**Response:** +```json +[ + { + "date": "2026-02-05", + "path": "daily/2026-02-05.md", + "created": "2026-02-05T08:00:00Z", + "updated": "2026-02-05T12:00:00Z" + } +] +``` + +### Get Today's Note + +```http +GET /api/daily/today +``` + +Creates the daily note if it doesn't exist. + +**Response:** +```json +{ + "date": "2026-02-05", + "content": "# 2026-02-05\n\n## Todo\n\n- [ ] Task 1", + "path": "daily/2026-02-05.md", + "created": "2026-02-05T08:00:00Z", + "updated": "2026-02-05T12:00:00Z" +} +``` + +### Get/Create Daily Note by Date + +```http +GET /api/daily/:date +POST /api/daily/:date +``` + +Date format: `YYYY-MM-DD` + +--- + +## Assets + +### Upload Asset + +```http +POST /api/assets/upload +Content-Type: multipart/form-data + +project: ferrite +file: (binary data) +``` + +**Response:** +```json +{ + "url": "/api/assets/ferrite/image-20260205-123456.png", + "filename": "image-20260205-123456.png" +} +``` + +### Get Asset + +```http +GET /api/assets/:project/:filename +``` + +Returns the binary file with appropriate Content-Type header. + +--- + +## Search + +### Search Content + +```http +GET /api/search?q=search+term +``` + +**Response:** +```json +{ + "results": [ + { + "path": "notes/20260205-123456.md", + "title": "My Note", + "matches": [ + { + "line": 5, + "text": "This is a **search term** example" + } + ] + } + ] +} +``` + +--- + +## Git Operations + +### Get Status + +```http +GET /api/git/status +``` + +**Response:** +```json +{ + "branch": "main", + "ahead": 2, + "behind": 0, + "staged": [], + "modified": ["notes/20260205-123456.md"], + "untracked": [], + "has_conflicts": false +} +``` + +### Commit Changes + +```http +POST /api/git/commit +Content-Type: application/json + +{ + "message": "Update notes" +} +``` + +### Push to Remote + +```http +POST /api/git/push +``` + +### Fetch from Remote + +```http +POST /api/git/fetch +``` + +### Get Commit Log + +```http +GET /api/git/log?limit=20 +``` + +**Response:** +```json +[ + { + "id": "abc123...", + "message": "Update notes", + "author": "User Name", + "date": "2026-02-05T12:34:56Z", + "files_changed": 3 + } +] +``` + +### Get Working Directory Diff + +```http +GET /api/git/diff +``` + +**Response:** +```json +{ + "diff": "diff --git a/notes/... " +} +``` + +### Get Commit Diff + +```http +GET /api/git/diff/:commitId +``` + +### Get Remote Info + +```http +GET /api/git/remote +``` + +**Response:** +```json +{ + "name": "origin", + "url": "git@github.com:user/repo.git", + "ahead": 2, + "behind": 0 +} +``` + +### Check for Conflicts + +```http +GET /api/git/conflicts +``` + +**Response:** +```json +{ + "has_conflicts": false, + "files": [] +} +``` + +--- + +## WebSocket + +### Connect + +``` +WS /ws +``` + +### Messages (Client → Server) + +**Lock File:** +```json +{ + "type": "lock_file", + "path": "notes/20260205-123456.md", + "lock_type": "editor" +} +``` + +**Unlock File:** +```json +{ + "type": "unlock_file", + "path": "notes/20260205-123456.md" +} +``` + +### Messages (Server → Client) + +**File Locked:** +```json +{ + "type": "file_locked", + "path": "notes/20260205-123456.md", + "client_id": "client-123" +} +``` + +**File Unlocked:** +```json +{ + "type": "file_unlocked", + "path": "notes/20260205-123456.md" +} +``` + +**File Modified (broadcast):** +```json +{ + "type": "file_modified", + "path": "notes/20260205-123456.md" +} +``` + +**Git Status Update:** +```json +{ + "type": "git_status", + "status": { ... } +} +``` + +--- + +## Error Responses + +All endpoints return errors in this format: + +```json +{ + "error": "Human-readable error message", + "code": "ERROR_CODE" +} +``` + +### Common Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `NOT_FOUND` | 404 | Resource doesn't exist | +| `BAD_REQUEST` | 400 | Invalid request data | +| `CONFLICT` | 409 | Resource conflict (e.g., Git) | +| `INTERNAL_ERROR` | 500 | Server error | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f5d2211 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,416 @@ +# Ironpad Architecture + +This document describes the technical architecture of Ironpad. + +## System Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Browser │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Vue 3 SPA ││ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ ││ +│ │ │ Views │ │Components│ │ Stores │ │ Composables │ ││ +│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ ││ +│ │ │ │ │ │ ││ +│ │ └─────────────┴──────┬──────┴───────────────┘ ││ +│ │ │ ││ +│ │ ┌───────▼───────┐ ││ +│ │ │ API Client │ ││ +│ └────────────────────┴───────┬───────┴────────────────────────┘│ +└───────────────────────────────┼─────────────────────────────────┘ + │ + HTTP REST │ WebSocket + │ +┌───────────────────────────────┼─────────────────────────────────┐ +│ │ │ +│ ┌────────────────────────────▼────────────────────────────────┐│ +│ │ Axum Router ││ +│ │ ┌─────────────────────────────────────────────────────────┐││ +│ │ │ Routes │││ +│ │ │ /api/notes /api/projects /api/tasks /api/git /ws │││ +│ │ └───────────────────────────┬─────────────────────────────┘││ +│ └──────────────────────────────┼───────────────────────────────┘│ +│ │ │ +│ ┌──────────────────────────────▼───────────────────────────────┐│ +│ │ Services ││ +│ │ ┌──────────┐ ┌───────────┐ ┌──────┐ ┌───────┐ ┌──────┐ ││ +│ │ │Filesystem│ │Frontmatter│ │ Git │ │Search │ │Locks │ ││ +│ │ └────┬─────┘ └─────┬─────┘ └──┬───┘ └───┬───┘ └──┬───┘ ││ +│ └───────┼──────────────┼───────────┼──────────┼─────────┼──────┘│ +│ │ │ │ │ │ │ +│ └──────────────┴─────┬─────┴──────────┴─────────┘ │ +│ │ │ +│ ┌──────▼──────┐ │ +│ │ File System │ │ +│ │ (data/) │ │ +│ └─────────────┘ │ +│ │ +│ Rust Backend │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Core Principles + +### 1. Files Are the Database + +All data is stored as Markdown files with YAML frontmatter: + +```markdown +--- +id: note-20260205-123456 +title: My Note +created: 2026-02-05T12:34:56Z +updated: 2026-02-05T12:34:56Z +--- + +# My Note + +Content goes here... +``` + +**Benefits:** +- Portable — files can be copied, backed up, synced +- Editable — any text editor works +- Versionable — Git tracks all changes +- Debuggable — human-readable format + +### 2. Backend Owns Metadata + +The backend automatically manages: +- `id` — Generated from timestamp (YYYYMMDD-HHMMSS) +- `created` — Set once when file is created +- `updated` — Updated on every save + +Clients send content; backend handles metadata consistency. + +### 3. Local-First + +The application works fully offline: +- No cloud dependencies +- No external API calls +- Git push is optional + +## Backend Architecture + +### Technology Stack + +- **Rust** — Memory safety, performance +- **Axum 0.8** — Async web framework +- **Tokio** — Async runtime +- **serde/serde_yaml** — Serialization +- **notify** — File system watching + +### Service Layer + +``` +services/ +├── filesystem.rs # File read/write operations +├── frontmatter.rs # YAML parsing/generation +├── git.rs # Git CLI wrapper +├── locks.rs # File locking state +├── markdown.rs # Markdown utilities +└── search.rs # ripgrep integration +``` + +#### Filesystem Service + +Handles all file operations with atomic writes: + +```rust +// Atomic write pattern +fn write_note(path: &Path, content: &str) -> Result<()> { + let temp = path.with_extension("tmp"); + fs::write(&temp, content)?; + fs::rename(temp, path)?; // Atomic on most filesystems + Ok(()) +} +``` + +#### Frontmatter Service + +Parses and generates YAML frontmatter: + +```rust +struct Frontmatter { + id: String, + title: Option, + created: DateTime, + updated: DateTime, + // ... other fields +} +``` + +#### Git Service + +Wraps Git CLI commands: + +```rust +impl GitService { + fn status(&self) -> Result; + fn commit(&self, message: &str) -> Result<()>; + fn push(&self) -> Result<()>; + fn log(&self, limit: usize) -> Result>; + fn diff(&self, commit: Option<&str>) -> Result; +} +``` + +Auto-commit runs every 60 seconds when changes exist. + +### WebSocket System + +Real-time updates via WebSocket: + +``` +Client Server + │ │ + │──── connect ─────────▶│ + │◀─── accepted ─────────│ + │ │ + │──── lock_file ───────▶│ + │◀─── file_locked ──────│ + │ │ + │ │ (file changed on disk) + │◀─── file_modified ────│ + │ │ + │──── unlock_file ─────▶│ + │◀─── file_unlocked ────│ +``` + +**Message Types:** +- `lock_file` / `unlock_file` — File locking for concurrent editing +- `file_modified` — Broadcast when files change on disk +- `git_status` — Git status updates + +### File Watcher + +Uses `notify` crate to watch the data directory: + +```rust +// Debounce: 500ms to batch rapid changes +// Filter: Ignores changes from own writes +watcher.watch(data_path, RecursiveMode::Recursive)?; +``` + +## Frontend Architecture + +### Technology Stack + +- **Vue 3** — Composition API +- **TypeScript** — Type safety +- **Vite** — Build tooling +- **Pinia** — State management +- **Vue Router** — Navigation +- **Milkdown** — WYSIWYG editor + +### Component Hierarchy + +``` +App.vue +├── Sidebar.vue +│ ├── NoteList.vue +│ ├── ProjectList.vue +│ └── GitStatus.vue +├── TopBar.vue +├── SearchPanel.vue +├── GitPanel.vue +└── + ├── NotesView.vue + ├── ProjectView.vue + ├── ProjectNotesView.vue + ├── TasksView.vue + └── DailyView.vue +``` + +### State Management (Pinia) + +Each domain has a dedicated store: + +```typescript +// Example: notesStore +export const useNotesStore = defineStore('notes', () => { + const notes = ref([]) + const currentNote = ref(null) + const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle') + + async function loadNote(id: string) { ... } + async function saveNote(content: string) { ... } + + return { notes, currentNote, saveStatus, loadNote, saveNote } +}) +``` + +### Milkdown Editor Integration + +The editor uses a two-component architecture: + +``` +MilkdownEditor.vue (wrapper) +└── MilkdownEditorCore.vue (actual editor) +``` + +**Critical Lifecycle:** + +1. `MilkdownProvider` provides Vue context +2. `useEditor` hook creates `Crepe` instance +3. `Crepe.editor` is the ProseMirror editor +4. `editor.action(replaceAll(content))` updates content + +**Key Pattern:** Content must be set BEFORE the editor key changes: + +```javascript +// View component +watch(noteId, async (newId) => { + const note = await api.getNote(newId) + + // CORRECT ORDER: + editorContent.value = note.content // 1. Set content + editorKey.value = newId // 2. Recreate editor +}) +``` + +### Auto-save System + +Smart auto-save that prevents unnecessary saves: + +```javascript +// Track original content +const lastSavedContent = ref(null) + +// Only save when content differs +watch(editorContent, (newContent) => { + if (lastSavedContent.value !== null && + newContent !== lastSavedContent.value) { + scheduleAutoSave() // 1-second debounce + } +}) +``` + +## Data Model + +### Note + +```typescript +interface Note { + id: string // e.g., "20260205-123456" + title?: string + path: string // e.g., "notes/20260205-123456.md" + created: string // ISO 8601 + updated: string +} + +interface NoteWithContent extends Note { + content: string // Markdown body +} +``` + +### Project + +```typescript +interface Project { + id: string // e.g., "ferrite" (slug) + title: string + description?: string + path: string + created: string + updated: string +} +``` + +### Task + +```typescript +interface Task { + id: string // e.g., "task-20260205-123456" + title: string + completed: boolean + section?: string // "Active" | "Backlog" + priority?: string + due_date?: string + is_active: boolean + content: string // Markdown description + path: string + created: string + updated: string +} +``` + +## API Design + +### REST Conventions + +- `GET /api/resource` — List all +- `POST /api/resource` — Create new +- `GET /api/resource/:id` — Get one +- `PUT /api/resource/:id` — Update +- `DELETE /api/resource/:id` — Delete (usually archives) + +### Error Handling + +```json +{ + "error": "Note not found", + "code": "NOT_FOUND" +} +``` + +HTTP status codes: +- `200` — Success +- `201` — Created +- `400` — Bad request +- `404` — Not found +- `500` — Server error + +## Security Considerations + +### Current State + +Ironpad is designed for **local, single-user** operation: + +- No authentication (local access assumed) +- No HTTPS (localhost only) +- No input sanitization for XSS (trusted user) + +### Production Deployment + +For multi-user or remote deployment: + +1. Add authentication (JWT, session-based) +2. Enable HTTPS +3. Sanitize markdown output +4. Rate limit API endpoints +5. Validate file paths to prevent directory traversal + +## Performance Considerations + +### Backend + +- **Atomic writes** — Prevent corruption on crash +- **File caching** — Read once, cache in memory (not yet implemented) +- **Ripgrep search** — Fast full-text search + +### Frontend + +- **Virtual scrolling** — For large note lists (not yet needed) +- **Debounced saves** — 1-second delay batches rapid edits +- **Lazy loading** — Routes loaded on demand + +## Future Considerations + +### Scalability + +Current design handles ~5000 files comfortably. For larger datasets: + +- Add Tantivy full-text search index +- Implement pagination for note lists +- Add lazy loading for project trees + +### Features + +See `ai-context.md` for planned features: + +- Tag extraction and filtering +- Backlinks between notes +- Graph view +- Export (PDF/HTML) +- Custom themes diff --git a/docs/ai-workflow/CHECKLIST.md b/docs/ai-workflow/CHECKLIST.md new file mode 100644 index 0000000..7a895b4 --- /dev/null +++ b/docs/ai-workflow/CHECKLIST.md @@ -0,0 +1,221 @@ +# Ironpad — Implementation Checklist + +This checklist tracks **what is planned vs what is done**, based on the PRD. +It is the authoritative execution status for the project. + +--- + +## Phase 0 — Preparation ✅ (COMPLETED) + +### Repository & Tooling +- [x] Create project root structure +- [x] Initialize Rust backend (`ironpad`, edition 2021) +- [x] Add backend dependencies (Axum, Tokio, notify, git2, etc.) +- [x] Verify backend builds (`cargo check`) + +### Backend Scaffolding +- [x] Create `routes/`, `services/`, `models/` modules +- [x] Create placeholder files for all planned backend components +- [x] Prepare WebSocket and file watcher modules + +### Data Layer +- [x] Create `data/` directory structure +- [x] Create initial files (`index.md`, `inbox.md`) +- [x] Initialize `data/` as its own Git repository + +### Project Meta +- [x] Create `ai-context.md` +- [x] Create implementation checklist + +--- + +## Phase 1 — MVP ✅ (COMPLETED) + +### Backend Core +- [x] Implement `main.rs` (Axum server bootstrap) +- [x] Dynamic port selection (3000–3010) +- [x] Auto-open system browser on startup +- [x] Serve static frontend files (production path) + +### Notes (CRUD) +- [x] List notes from filesystem +- [x] Read markdown file by ID +- [x] Create new note with auto-generated frontmatter +- [x] Update note with auto-save + timestamp update +- [x] Archive note on delete (move to `data/archive/`) + +### Frontmatter Automation +- [x] Parse/serialize frontmatter +- [x] Deterministic ID from path +- [x] Auto-manage `created`/`updated` timestamps +- [x] Preserve user-defined fields + +### Frontend (Basic) +- [x] Vue 3 + Vite setup +- [x] Note list sidebar +- [x] Note viewer/editor (textarea) +- [x] Create/archive note actions +- [x] Auto-save on edit + +--- + +## Phase 2 — Core Daily Driver ✅ (COMPLETED) + +### Real-Time Sync +- [x] File system watching (`notify` crate) +- [x] WebSocket server for real-time updates +- [x] External edit detection + UI notifications +- [x] Filter out own saves from notifications + +### Search +- [x] Full-text search (ripgrep with fallback) +- [x] Search endpoint (`GET /api/search?q=`) +- [x] UI search integration (Ctrl+K) + +### Git Integration +- [x] Git status endpoint +- [x] Auto-commit (60-second batching) +- [x] Manual commit button +- [x] Git status indicator in UI + +### Projects +- [x] Project creation (folder + `index.md` + `assets/`) +- [x] List projects API +- [x] Project task file creation (`tasks.md`) + +### Tasks (Basic) +- [x] Task parsing from markdown checkboxes +- [x] Tasks API (list all tasks) +- [x] Task view in sidebar + +--- + +## Phase 3 — Full PRD Compliance ✅ (COMPLETED) + +### Projects & Tasks (Per PRD Section 7.2, 7.3) +- [x] Project-specific task endpoint (`GET /api/projects/:id/tasks`) +- [x] Task toggle endpoint (update checkbox state) +- [x] Add task via UI (append to `tasks.md`) +- [x] Task sections: Active, Completed, Backlog +- [x] Project task view at `/projects/:id/tasks` route + +### File Locking (Per PRD Section 7.7) +- [x] Backend tracks open files via WebSocket +- [x] File lock when Task View opens +- [x] Editor shows "Read-Only" if file locked +- [x] Auto-unlock when view closes + +### Daily Notes (Per PRD Section 6) +- [x] Create `data/daily/` directory +- [x] Daily note endpoint (create/get today's note) +- [x] Daily note templates +- [x] Daily notes in sidebar + +### CodeMirror 6 Editor (Per PRD Section 9.3) +- [x] Install CodeMirror 6 dependencies +- [x] Replace textarea with CodeMirror +- [x] Markdown syntax highlighting +- [x] Line numbers +- [x] Keyboard shortcuts + +### Markdown Preview (Per PRD Section 5) +- [x] Split view (editor + preview) +- [x] Markdown-it rendering +- [x] CommonMark consistency + +### Assets API (Per PRD Section 8) +- [x] `POST /api/assets/upload` endpoint +- [x] `GET /api/assets/:project/:file` endpoint +- [x] Image upload UI in editor + +### Git Advanced (Per PRD Section 7.5) +- [x] Git conflict detection +- [x] Conflict warning banner in UI +- [x] `POST /api/git/push` endpoint +- [x] `GET /api/git/conflicts` endpoint + +### Frontend Architecture (Per PRD Section 14) +- [x] Vue Router for navigation +- [x] Pinia state management +- [x] Separate view components (NotesView, TasksView, ProjectView) +- [x] WebSocket composable + +--- + +## Phase 4 — Enhanced Task System ✅ (COMPLETED) + +### Dashboard +- [x] Cross-project dashboard as home page (`/`) +- [x] Project cards with active task counts and summaries +- [x] Click-through to project or task detail +- [x] Clickable "Ironpad" title navigates to dashboard + +### Tags +- [x] Tags field in task frontmatter (YAML sequence) +- [x] Backend parses/writes tags on task CRUD +- [x] Tag pills displayed on task list items +- [x] Tag filter bar — click to filter tasks by tag +- [x] Tag editor in task detail panel with autocomplete +- [x] `projectTags` computed getter for all unique tags in project + +### Subtasks +- [x] `parent_id` field in task frontmatter +- [x] Backend accepts `parent_id` on task creation +- [x] Task list groups subtasks under parent (indented) +- [x] Subtask count badge on parent tasks (completed/total) +- [x] Subtask panel in task detail with inline add +- [x] Subtasks clickable to view/edit + +### Recurring Tasks +- [x] `recurrence` and `recurrence_interval` fields in frontmatter +- [x] Backend auto-creates next instance on recurring task completion +- [x] Due date advanced by interval (daily/weekly/monthly/yearly) +- [x] Recurrence picker (dropdown) in task detail panel +- [x] Recurrence indicator on task list items + +### Calendar View +- [x] Month grid calendar at `/calendar` +- [x] Tasks with due dates plotted on day cells +- [x] Daily notes shown as blue dots +- [x] Color-coded urgency (overdue/today/soon) +- [x] Month navigation (prev/next) + Today button +- [x] Click task → navigate to detail, click date → daily note +- [x] Calendar link in sidebar navigation + +### Due Date +- [x] Inline date picker in task detail panel +- [x] Clear due date button +- [x] Due date display with color-coded urgency on task items + +--- + +## Phase 5 — Polish + +- [ ] UI polish and animations +- [ ] Responsive sidebar +- [ ] Better error handling/messages +- [ ] Loading states + +--- + +## Phase 6 — Future / Optional + +- [ ] Global hotkey (Ctrl+Shift+Space) +- [ ] System tray mode +- [ ] Backlinks between notes +- [ ] Graph view +- [ ] Export (PDF / HTML) +- [ ] Custom themes +- [ ] Tantivy search (if >5000 notes) +- [ ] Task dependencies (blocked by) +- [ ] Time estimates on tasks +- [ ] Calendar drag-and-drop rescheduling +- [ ] Week/day calendar views + +--- + +## Rules + +- No item is marked complete unless it is implemented and verified. +- New features must be added to this checklist before implementation. +- If it's not on this list, it's out of scope. diff --git a/docs/ai-workflow/HANDOVER.md b/docs/ai-workflow/HANDOVER.md new file mode 100644 index 0000000..9606bab --- /dev/null +++ b/docs/ai-workflow/HANDOVER.md @@ -0,0 +1,128 @@ +# Ironpad - Chat Handover Document + +**Date:** 2026-02-05 +**Context:** See `ai-context.md` for full project overview + +--- + +## Session Summary + +This session focused on fixing a critical bug where **notes and tasks displayed stale/wrong content** when switching between items. The issue caused data loss as the wrong content was being saved to the wrong files. + +--- + +## What Was Fixed + +### Problem: Stale Content When Switching Notes/Tasks + +**Symptoms:** +- Click note A → shows content correctly +- Click note B → still shows note A's content +- Refresh sometimes fixes it, sometimes shows blank +- Auto-save then overwrites note B with note A's content (DATA LOSS) + +**Root Cause:** +The Milkdown WYSIWYG editor wasn't properly recreating when switching items. Two issues: + +1. **Module-level variables in `MilkdownEditorCore.vue`** - State like `currentContent` was persisting across component recreations because they were `let` variables instead of Vue `ref`s. + +2. **Race condition in view components** - The editor key was changing BEFORE content was loaded: + ``` + noteId changes → editor recreates with empty content → content loads → too late + ``` + +**Solution:** +1. Converted module-level `let` variables to `ref`s in `MilkdownEditorCore.vue` +2. Added retry mechanism for applying pending content +3. Introduced separate `editorKey` ref in all view components that only updates AFTER content is loaded +4. Added guards to prevent emitting stale content + +**Files Modified:** +- `frontend/src/components/MilkdownEditorCore.vue` +- `frontend/src/components/MilkdownEditor.vue` +- `frontend/src/views/ProjectNotesView.vue` +- `frontend/src/views/TasksView.vue` +- `frontend/src/views/NotesView.vue` + +--- + +## Outstanding Issues + +All major issues from this session have been resolved: + +1. **Auto-save aggressiveness** - FIXED: Now tracks "last saved content" and only saves when actual changes are made +2. **Documentation** - FIXED: Added README.md, docs/ARCHITECTURE.md, docs/API.md + +--- + +## Technical Context for Future Sessions + +### Milkdown Editor Lifecycle (Critical Knowledge) + +The Milkdown editor (WYSIWYG markdown) has a complex lifecycle: + +1. `MilkdownProvider` provides Vue context +2. `useEditor` hook creates the `Crepe` instance +3. `Crepe.editor` is the actual Milkdown Editor +4. `editor.action(replaceAll(content))` updates content +5. BUT `editor.action` isn't immediately available after `useEditor` returns + +**Key Pattern:** Always set content BEFORE changing the editor key: +```javascript +// CORRECT +editorContent.value = newContent +editorKey.value = newId // Editor recreates with correct defaultValue + +// WRONG +editorKey.value = newId // Editor recreates with stale/empty content +editorContent.value = newContent // Too late! +``` + +### Project Structure + +``` +ironpad/ +├── backend/ # Rust Axum server (API only) +├── frontend/ # Vue 3 SPA +│ └── src/ +│ ├── components/ +│ │ ├── MilkdownEditor.vue # Wrapper component +│ │ └── MilkdownEditorCore.vue # Actual editor (key file!) +│ ├── views/ +│ │ ├── NotesView.vue # Standalone notes +│ │ ├── ProjectNotesView.vue # Project-specific notes +│ │ └── TasksView.vue # Project tasks +│ └── stores/ # Pinia state management +└── data/ # Markdown files (source of truth) +``` + +--- + +## Recommended Next Steps + +1. ~~**Fix auto-save aggressiveness**~~ - DONE: Uses `lastSavedContent` to track actual changes +2. ~~**Create proper README.md**~~ - DONE: See `/README.md`, `/frontend/README.md` +3. ~~**Add developer documentation**~~ - DONE: See `/docs/ARCHITECTURE.md`, `/docs/API.md` +4. **Consider adding tests** - At minimum, test the content switching logic + +--- + +## Commands Reference + +```bash +# Backend (from backend/) +cargo run # API server on :3000 + +# Frontend (from frontend/) +npm run dev # Dev server on :5173 +npm run build # Production build +``` + +--- + +## Notes + +- Windows + PowerShell environment +- Files are the database (no SQL) +- Git auto-commits every 60 seconds +- See `ai-context.md` for full feature list and API endpoints diff --git a/docs/ai-workflow/PRD.md b/docs/ai-workflow/PRD.md new file mode 100644 index 0000000..637fb63 --- /dev/null +++ b/docs/ai-workflow/PRD.md @@ -0,0 +1,1004 @@ +# Product Requirements Document (PRD) +## Ironpad - Personal Project & Knowledge Management System + +**Version:** 3.0 +**Date:** 2026-02-02 +**Status:** Active Development +**Author:** Internal / Personal +**Changelog:** v3.0 - Addressed concurrency, file watching, git conflicts, port handling, and frontmatter automation + +--- + +## 1. Executive Summary + +**Ironpad** is a **local-first, browser-based personal project management and note-taking system** powered by a Rust backend. + +The system combines: +- Free-form markdown notes +- Task management per project +- Project organization +- Full-text search +- Git-based versioning +- **Real-time file system watching** for external edits +- **Automatic frontmatter management** for low-ceremony UX + +**Core Philosophy:** Simplicity first, power through composition, not rigid workflows. + +**Key Innovation:** Single-binary Rust executable that auto-opens your browser - no bundled Chromium, no Node.js runtime required. + +--- + +## 2. Goals + +### Primary Goals +- Provide a single place to store thoughts, notes, and tasks +- Enable lightweight project organization without heavy structure +- Support incremental evolution of features +- Keep the system easy to understand, modify, and extend +- Learn Rust through practical application +- **Eliminate manual metadata management** (auto-update timestamps, IDs) +- **Support external editing** (VS Code, Obsidian, etc.) + +### Technical Goals +- Zero-dependency data storage (plain files only) +- Fast search (<100ms for 1000 notes) +- Responsive UI (<16ms frame time) +- Startup time <500ms +- Tiny binary size (<15 MB) +- No browser bundling (use system browser) +- **Graceful port conflict handling** +- **Real-time sync between UI and filesystem** +- **Robust git conflict handling** + +### Non-Goals (v1) +- Multi-user collaboration +- Cloud sync (Git remote is optional) +- Permissions/roles +- Complex workflow automation +- Enterprise-grade task management +- Mobile app (mobile access not required) + +--- + +## 3. Design Principles + +### 1. Local-First +- No external services required +- Works fully offline +- Data stored on local file system +- **File system is the source of truth** (not UI state) + +### 2. Notes-First, Tasks-Per-Project +- Notes are the primary unit +- Each project has its own task list +- Tasks don't float independently + +### 3. File-Based Storage +- Data stored as Markdown files +- Editable outside the app +- Human-readable formats only +- **External editors fully supported** (VS Code, Obsidian, Vim) + +### 4. Low Ceremony +- Minimal configuration +- Minimal UI friction +- No complex setup wizards +- **Automatic metadata management** (no manual timestamps/IDs) + +### 5. Future-Proof +- Easy to migrate to other tools +- Git-friendly formats +- No vendor lock-in + +### 6. Simplicity Principles +- **No abstraction layers** - Files are the database +- **No build step for data** - Markdown is human-readable +- **No proprietary formats** - Everything is standard +- **No server required** - Runs entirely on localhost +- **No complex workflows** - Write, save, done + +--- + +## 4. Architecture + +### Overview + +``` +User launches executable + ↓ + Rust Backend (Axum) + - HTTP server on dynamic port (3000-3010) + - Serves Vue frontend (static files) + - REST API for file operations + - Git operations with conflict handling + - Full-text search + - File system watcher (notify crate) + - WebSocket server for real-time updates + ↓ + Auto-opens default browser + → http://localhost:{port} + ↓ + Vue 3 Frontend (in browser) + - Markdown editor + - Task management + - Project switching + - WebSocket client (receives file change events) + ↓ + Bidirectional real-time sync + ← WebSocket → File System Changes +``` + +### Why Not Electron? + +| **Electron** | **Ironpad (Rust)** | +|-------------|-------------------| +| 150-300 MB bundle | 5-15 MB binary | +| Bundles Chromium | Uses system browser | +| 200-500 MB RAM | 10-50 MB RAM | +| 2-5s startup | <500ms startup | +| Complex distribution | Single executable | + +**Rationale:** No need to bundle an entire browser when every user already has one. + +### Technology Stack + +#### Backend (Rust) +- **Axum** - Web framework (simple, fast, learning-friendly) +- **Tokio** - Async runtime +- **Tower** - Middleware +- **Serde** - JSON serialization +- **markdown-rs** - Markdown parsing with frontmatter (CommonMark compliant) +- **git2** - Git operations with lock handling +- **webbrowser** - Cross-platform browser launching +- **notify** - File system watching for external changes +- **axum-ws** or **tower-websockets** - WebSocket support for real-time updates +- **ripgrep** library or **tantivy** - Fast search + +#### Frontend (Vue 3) +- **Vue 3** - UI framework (Composition API) +- **Vite** - Build tool +- **CodeMirror 6** - Markdown editor with syntax highlighting +- **Pinia** - State management (minimal caching, trust filesystem) +- **markdown-it** - Markdown rendering (CommonMark mode for consistency) +- **Native WebSocket API** - Real-time file change notifications + +#### Data Storage +- **Markdown files** (.md) on local file system +- **YAML frontmatter** for metadata (auto-managed by backend) +- **Git repository** for versioning +- **No database** - files are the database + +--- + +## 5. User Experience + +### Mental Model +- Similar to a notebook system (OneNote / Obsidian) +- A **front page** (index.md) acts as entry point +- Users can create notes and projects +- Projects contain notes + dedicated task list +- Simple navigation via sidebar +- **Edit anywhere** - changes in VS Code/Obsidian auto-sync to UI +- **Conflict-free** - UI prevents simultaneous edits of same file + +### Core UI Areas + +#### 1. Sidebar (Left) +- **Projects** section + - List of all projects + - Quick switch between projects +- **Notes** section + - List of standalone notes + - Daily notes +- **Quick actions** + - New note + - New project + - Search +- **Git status indicator** (changes pending commit) + +#### 2. Main Area (Center) +- **Editor view** + - CodeMirror markdown editor + - Syntax highlighting + - Auto-save (2s debounce) + - **File lock indicator** (shows if file open in Task View) + - **External edit notification** (shows banner if file changed externally) +- **Split view option** + - Editor on left + - Preview on right + +#### 3. Task View (Separate Page) +- View: `/projects/:id/tasks` +- Shows tasks for currently selected project +- Parse checkboxes from `data/projects/{id}/tasks.md` +- Sections: Active, Completed, Backlog +- Quick checkbox toggle +- **Prevents editor access** - If Task View open, editor shows "Read-Only" mode + +--- + +## 6. Data Model + +### File Structure + +``` +data/ + .git/ # Git repository + index.md # Front page / landing + inbox.md # Quick capture + + daily/ # Daily notes (optional) + 2026-02-02.md + 2026-02-03.md + + projects/ # Project folders + ironpad/ + index.md # Project overview + tasks.md # Task list + notes.md # Miscellaneous notes (optional) + assets/ # Images, attachments + screenshot.png + homelab/ + index.md + tasks.md + assets/ + + notes/ # Standalone notes + ideas.md + rust-learning.md + assets/ # Shared assets + diagram.png + + archive/ # Completed/archived items + old-project/ +``` + +### Frontmatter Schema + +#### Standard Fields (All Files) - AUTO-MANAGED +```yaml +--- +# Auto-generated by backend (user never edits these manually) +id: ironpad-index # Derived from filename: {folder}-{filename} +type: note # Detected from file location +created: 2026-02-02T01:00:00Z # Set on file creation +updated: 2026-02-02T01:15:00Z # Auto-updated on every save + +# Optional user fields +title: Ironpad Development # Optional: display title (fallback: filename) +tags: [dev, rust, personal] # Optional: user-defined tags +status: active # Optional: draft|active|archived|complete +--- +``` + +**Key Change:** Users never manually write `id`, `created`, or `updated` fields. Backend handles these automatically. + +#### ID Generation Strategy +- **Format**: `{parent-folder}-{filename-without-extension}` +- **Examples**: + - `projects/ironpad/index.md` → `id: ironpad-index` + - `notes/ideas.md` → `id: notes-ideas` + - `daily/2026-02-02.md` → `id: daily-2026-02-02` +- **Rationale**: Human-readable, deterministic, no UUIDs cluttering files + +#### Task File Example +```yaml +--- +id: ironpad-tasks # Auto-generated from filename +type: tasks # Auto-detected +project_id: ironpad # Parent folder name +created: 2026-02-01T12:00:00Z +updated: 2026-02-02T01:00:00Z # Auto-updated on every save +--- + +# Tasks: Ironpad + +## Active +- [ ] Set up Rust backend with Axum +- [ ] Create Vue frontend with CodeMirror +- [ ] Implement task parsing + +## Completed +- [x] Write PRD +- [x] Review architecture decisions + +## Backlog +- [ ] Add full-text search +- [ ] Implement Git auto-commit +``` + +--- + +## 7. Functional Requirements + +### 7.1 Notes Management +- **Create** new notes via sidebar button +- **Read** note content with markdown rendering +- **Update** notes with auto-save (2s debounce after last edit) +- **Delete** notes (moves to archive/ folder) +- Notes stored as `.md` files in `data/notes/` +- **Auto-update** `updated` timestamp on every save + +### 7.2 Project Management +- **Create** new projects (creates `data/projects/{id}/` folder + `assets/` subfolder) +- **View** project overview (`index.md`) +- **Switch** between projects via sidebar +- **Archive** completed projects (moves to `archive/`) +- Projects automatically get `tasks.md` file + `assets/` folder + +### 7.3 Task Management +- **View** tasks per project at `/projects/:id/tasks` +- **Toggle** task completion (checkbox state) +- **Add** new tasks via UI (appends to tasks.md) +- **Organize** tasks in sections: Active, Completed, Backlog +- Tasks represented as Markdown checkboxes: + ```markdown + - [ ] Incomplete task + - [x] Completed task + ``` +- **No global task view in v1** - tasks belong to projects +- **Concurrency handling**: If Task View is open for a file, Editor View shows "Read-Only - Open in Task View" banner + +### 7.4 Search +- **Full-text search** across all markdown files +- Search triggered from sidebar search box +- Results show: filename, matching line, context +- **Implementation**: Use `ripgrep` as library (faster than naive grep) +- Performance target: <100ms for 1000 notes +- **Future**: Migrate to Tantivy if needed (>5000 notes) + +### 7.5 Git Integration +- **Auto-commit** with time-based batching (5 minutes) +- **Manual commit** button available +- **Commit message format**: `Auto-save: {timestamp}` or user-provided message +- **Git history** viewable via external tools (GitKraken, git log, etc.) +- **Remote push** optional (manual via git push or UI button) +- **Lock handling**: If `.git/index.lock` exists, skip auto-commit and retry next cycle +- **Conflict detection**: If `git status` shows conflicts, show warning banner in UI + +### 7.6 File System Watching (NEW) +- **Backend watches** `data/` directory using `notify` crate +- **Detects changes** made by external editors (VS Code, Obsidian, Vim) +- **Sends WebSocket message** to frontend: `{ type: "file_changed", path: "notes/ideas.md" }` +- **Frontend response**: + - If file is currently open in editor → Show banner: "File changed externally. Reload?" + - If file is not open → Auto-refresh sidebar file list + - If Task View is open for that file → Auto-reload task list +- **Debouncing**: Batch file changes (100ms) to avoid spamming WebSocket + +### 7.7 Concurrency Control (NEW) +- **Single-file lock**: Only one view (Editor OR Task View) can edit a file at a time +- **Implementation**: + - Backend tracks "open files" via WebSocket connection state + - When Task View opens `tasks.md`, backend marks file as "locked for task editing" + - If user tries to open same file in Editor, show "Read-Only" mode + - When Task View closes, file unlocked automatically +- **Rationale**: Prevents race conditions between checkbox toggles and text edits + +--- + +## 8. API Design + +### REST Endpoints + +#### Notes +``` +GET /api/notes # List all notes +GET /api/notes/:id # Get note content +POST /api/notes # Create new note (auto-generates frontmatter) +PUT /api/notes/:id # Update note content (auto-updates 'updated' field) +DELETE /api/notes/:id # Delete note (archive) +``` + +#### Projects +``` +GET /api/projects # List all projects +GET /api/projects/:id # Get project details +POST /api/projects # Create new project (creates folder + assets/) +PUT /api/projects/:id # Update project +DELETE /api/projects/:id # Archive project +``` + +#### Tasks +``` +GET /api/projects/:id/tasks # Get tasks for project +PUT /api/projects/:id/tasks # Update tasks for project +POST /api/tasks/lock/:id # Lock file for task editing +POST /api/tasks/unlock/:id # Unlock file +``` + +#### Search +``` +GET /api/search?q={query} # Search all content (ripgrep-powered) +``` + +#### Git +``` +POST /api/git/commit # Manual commit with message +GET /api/git/status # Get git status (detects conflicts) +POST /api/git/push # Push to remote (if configured) +GET /api/git/conflicts # Check for merge conflicts +``` + +#### Assets (NEW) +``` +POST /api/assets/upload # Upload image/file to project assets/ +GET /api/assets/:project/:file # Retrieve asset file +``` + +### WebSocket Endpoints (NEW) + +``` +WS /ws # WebSocket connection for real-time updates + +# Messages from backend → frontend: +{ type: "file_changed", path: "notes/ideas.md", timestamp: "2026-02-02T01:00:00Z" } +{ type: "file_deleted", path: "notes/old.md" } +{ type: "file_created", path: "daily/2026-02-03.md" } +{ type: "git_conflict", files: ["notes/ideas.md"] } + +# Messages from frontend → backend: +{ type: "subscribe_file", path: "notes/ideas.md" } # Track active file +{ type: "unsubscribe_file", path: "notes/ideas.md" } +``` + +--- + +## 9. Technical Implementation Details + +### 9.1 Auto-Save Strategy +- **Decision**: 2-second debounce after last edit +- **Rationale**: Balance between data safety and performance +- **Implementation**: Frontend debounces PUT requests +- **Backend behavior**: On PUT, auto-update `updated` timestamp in frontmatter + +### 9.2 Git Commit Strategy +- **Decision**: Time-based batching (5 minutes) + manual commit button +- **Rationale**: Clean history, reduced I/O, user control +- **Implementation**: + - Rust background task (tokio::spawn) runs every 5 minutes + - Checks `git status --porcelain` for changes + - If `.git/index.lock` exists → Skip and log warning + - If changes exist → `git add .` → `git commit -m "Auto-save: {timestamp}"` + - If commit fails (lock, conflict) → Show error in UI via WebSocket + +### 9.3 Editor Choice +- **Decision**: CodeMirror 6 with markdown mode +- **Rationale**: Mature, performant, excellent UX out-of-box +- **Features**: Syntax highlighting, line numbers, keyboard shortcuts + +### 9.4 Search Implementation (v1) +- **Decision**: Use `ripgrep` as library (via `grep` crate or direct integration) +- **Rationale**: + - Faster than naive grep (uses SIMD, optimized algorithms) + - Handles >1000 files easily + - Used by VS Code, Sublime Text +- **Future**: Migrate to Tantivy if search becomes slow (>5000 notes) + +### 9.5 Browser Launch +- **Decision**: Use `webbrowser` crate to open default browser +- **Rationale**: Cross-platform, simple, no browser bundling +- **Fallback**: Print URL if browser launch fails + +### 9.6 Port Conflict Handling (NEW) +- **Decision**: Dynamic port selection (3000-3010 range) +- **Implementation**: + ```rust + async fn find_available_port() -> u16 { + for port in 3000..=3010 { + if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)).await { + return port; + } + } + panic!("No available ports in range 3000-3010"); + } + ``` +- **User experience**: Always works, even if other dev tools running +- **Logging**: Print actual port used: `🚀 Ironpad running on http://localhost:3005` + +### 9.7 File System Watching (NEW) +- **Decision**: Use `notify` crate with debouncing +- **Implementation**: + ```rust + use notify::{Watcher, RecursiveMode, Event}; + + let (tx, rx) = channel(); + let mut watcher = notify::recommended_watcher(tx)?; + watcher.watch(Path::new("data/"), RecursiveMode::Recursive)?; + + // Debounce: Collect events for 100ms, then broadcast via WebSocket + ``` +- **Events tracked**: Create, Modify, Delete +- **Ignored paths**: `.git/`, `node_modules/`, `.DS_Store` + +### 9.8 Frontmatter Automation (NEW) +- **Decision**: Backend owns all frontmatter management +- **Implementation**: + - On file creation: Generate frontmatter with `id`, `type`, `created`, `updated` + - On file update: Parse YAML, update `updated` field, rewrite file + - Use `gray_matter` or `serde_yaml` crates +- **User experience**: Users never manually edit timestamps or IDs + +### 9.9 Markdown Consistency (NEW) +- **Decision**: Use CommonMark standard everywhere +- **Backend**: `markdown-rs` (CommonMark compliant) +- **Frontend**: `markdown-it` with CommonMark preset +- **Rationale**: Prevents rendering mismatches between preview and backend parsing + +--- + +## 10. Build & Distribution + +### Development Workflow + +```bash +# Terminal 1: Frontend dev server with hot reload +cd frontend +npm install +npm run dev # Runs on localhost:5173 + +# Terminal 2: Rust backend (proxies frontend) +cd backend +cargo run # Runs on localhost:3000-3010, opens browser +``` + +### Production Build + +```bash +# Step 1: Build frontend +cd frontend +npm run build # Outputs to frontend/dist/ + +# Step 2: Copy frontend to Rust static folder +cp -r frontend/dist/* backend/static/ + +# Step 3: Build Rust release binary +cd backend +cargo build --release + +# Output: backend/target/release/ironpad +# Size: ~5-15 MB +``` + +### Distribution +- **Single executable** - `ironpad.exe` (Windows), `ironpad` (Mac/Linux) +- **No installer required** - Just run the binary +- **No dependencies** - Statically linked (except libc) +- **User experience**: + 1. User downloads `ironpad.exe` + 2. User double-clicks executable + 3. Browser opens automatically to available port + 4. App is ready to use + +--- + +## 11. Data Safety & Backup + +### Git as Primary Backup +- Every save eventually commits to local Git repo +- Repo can be pushed to remote (GitHub, GitLab, self-hosted) +- All history preserved indefinitely + +### Recommended Setup +```bash +# Initialize repo (done automatically on first run) +cd data +git init + +# Add remote (optional, manual or via UI) +git remote add origin https://github.com/yourusername/ironpad-data.git + +# Auto-push (future feature) +# UI button: "Push to Remote" +``` + +### Disaster Recovery +- Clone repo on new machine +- Point Ironpad to cloned folder +- All history and data preserved +- No proprietary formats to migrate + +### Conflict Handling +- If `.git/index.lock` exists → Skip auto-commit, retry next cycle +- If `git status` shows conflicts → Show banner in UI: "Git conflicts detected. Resolve manually." +- Never auto-merge conflicts → User must resolve via git CLI or external tool + +--- + +## 12. Success Criteria + +The system is successful if: +- ✅ Used daily for notes and tasks +- ✅ Data remains readable without the app +- ✅ Adding new features does not require migrations +- ✅ System feels flexible rather than restrictive +- ✅ Binary size stays under 15 MB +- ✅ Startup time under 500ms +- ✅ Search completes in <100ms +- ✅ **External edits sync instantly** (<500ms latency) +- ✅ **No manual frontmatter editing required** +- ✅ **Never crashes due to port conflicts** + +### Usage Metrics (Personal Tracking) +- Daily notes created per week (target: 5+) +- Tasks completed per week (target: 10+) +- Projects with active tasks (target: 2-3) +- Average note length (target: 200+ words) +- External edits per week (VS Code usage, target: tracked but not enforced) + +### Performance Metrics +- App startup time (target: <500ms) +- Search response time (target: <100ms) +- Save operation time (target: <50ms) +- Binary size (target: <15 MB) +- File change notification latency (target: <500ms) +- WebSocket message latency (target: <100ms) + +--- + +## 13. Implementation Phases + +### Phase 1: MVP (Week 1-2) +**Goal**: Basic note CRUD + auto-open browser + dynamic port + +- [ ] Rust backend with Axum +- [ ] Dynamic port selection (3000-3010) +- [ ] File CRUD operations (read/write .md files) +- [ ] Automatic frontmatter generation (id, created, updated) +- [ ] Auto-open browser on launch +- [ ] Vue frontend scaffold +- [ ] Basic markdown editor (textarea) +- [ ] File list sidebar +- [ ] Auto-save with debounce + +### Phase 2: Core Features (Week 3-4) +**Goal**: Usable daily driver with real-time sync + +- [ ] CodeMirror 6 integration +- [ ] Project creation/switching (with assets/ folders) +- [ ] Task parsing (checkboxes in markdown) +- [ ] Task view per project +- [ ] File locking (Task View vs Editor) +- [ ] Git init + auto-commit with lock handling +- [ ] Ripgrep-based search +- [ ] **File system watching** (notify crate) +- [ ] **WebSocket server** for real-time updates +- [ ] External edit notifications in UI + +### Phase 3: Polish (Week 5-6) +**Goal**: Production-ready personal tool + +- [ ] Split view (editor + preview) +- [ ] Manual commit with message +- [ ] Git status/history viewer +- [ ] Git conflict detection and UI warnings +- [ ] Tag extraction and filtering +- [ ] Daily note templates +- [ ] UI polish and keyboard shortcuts +- [ ] Asset upload for images + +### Phase 4: Advanced (Future) +- [ ] Global hotkey (Ctrl+Shift+Space) using `global-hotkey` crate +- [ ] System tray icon (stays running in background) +- [ ] Tantivy full-text search (if ripgrep becomes slow) +- [ ] Backlinks between notes +- [ ] Remote Git push/pull from UI +- [ ] Export to PDF/HTML +- [ ] Custom themes + +--- + +## 14. Project Structure + +``` +ironpad/ +├── README.md +├── LICENSE +│ +├── backend/ # Rust backend +│ ├── Cargo.toml +│ ├── Cargo.lock +│ ├── src/ +│ │ ├── main.rs # Server startup + router + port detection +│ │ ├── websocket.rs # WebSocket handler for real-time updates +│ │ ├── watcher.rs # File system watcher (notify integration) +│ │ ├── routes/ +│ │ │ ├── mod.rs +│ │ │ ├── notes.rs # /api/notes endpoints +│ │ │ ├── projects.rs # /api/projects endpoints +│ │ │ ├── tasks.rs # /api/projects/:id/tasks +│ │ │ ├── search.rs # /api/search (ripgrep integration) +│ │ │ ├── git.rs # /api/git endpoints +│ │ │ └── assets.rs # /api/assets endpoints +│ │ ├── services/ +│ │ │ ├── mod.rs +│ │ │ ├── filesystem.rs # File read/write logic +│ │ │ ├── git.rs # Git operations (git2) with lock handling +│ │ │ ├── search.rs # Ripgrep search implementation +│ │ │ ├── markdown.rs # Markdown parsing (CommonMark) +│ │ │ ├── frontmatter.rs # Auto-manage YAML frontmatter +│ │ │ └── locks.rs # File lock management for concurrency +│ │ └── models/ +│ │ ├── mod.rs +│ │ ├── note.rs # Note struct +│ │ ├── project.rs # Project struct +│ │ └── task.rs # Task struct +│ └── static/ # Vue build output (in production) +│ └── (frontend dist files) +│ +├── frontend/ # Vue 3 frontend +│ ├── package.json +│ ├── vite.config.ts +│ ├── tsconfig.json +│ ├── index.html +│ ├── src/ +│ │ ├── main.ts +│ │ ├── App.vue +│ │ ├── composables/ +│ │ │ └── useWebSocket.ts # WebSocket client composable +│ │ ├── components/ +│ │ │ ├── Sidebar.vue +│ │ │ ├── ProjectList.vue +│ │ │ ├── NoteList.vue +│ │ │ ├── Editor.vue +│ │ │ ├── MarkdownPreview.vue +│ │ │ ├── TaskList.vue +│ │ │ ├── SearchBar.vue +│ │ │ ├── GitStatus.vue +│ │ │ ├── ExternalEditBanner.vue # Shows when file changed externally +│ │ │ └── ReadOnlyBanner.vue # Shows when file locked +│ │ ├── views/ +│ │ │ ├── NotesView.vue # Main notes editor +│ │ │ ├── TasksView.vue # Project tasks view +│ │ │ └── ProjectView.vue # Project overview +│ │ ├── stores/ +│ │ │ ├── notes.ts # Pinia store (minimal caching) +│ │ │ ├── projects.ts # Pinia store for projects +│ │ │ ├── tasks.ts # Pinia store for tasks +│ │ │ └── websocket.ts # WebSocket state management +│ │ ├── api/ +│ │ │ └── client.ts # API client (fetch wrappers) +│ │ └── types/ +│ │ └── index.ts # TypeScript types +│ └── dist/ # Build output (gitignored) +│ +└── data/ # User data (separate repo) + ├── .git/ + ├── .gitignore + ├── index.md + ├── inbox.md + ├── projects/ + │ └── ironpad/ + │ ├── index.md + │ ├── tasks.md + │ └── assets/ # Project-specific images + ├── notes/ + │ ├── ideas.md + │ └── assets/ # Shared note assets + ├── daily/ + └── archive/ +``` + +--- + +## 15. Dependencies + +### Backend (Cargo.toml) +```toml +[package] +name = "ironpad" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Web framework +axum = { version = "0.8", features = ["ws"] } # WebSocket support +tokio = { version = "1", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "cors"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" # Frontmatter parsing + +# Markdown parsing (CommonMark) +markdown = "1.0.0-alpha.22" # Frontmatter support + +# Git operations +git2 = "0.19" + +# Browser opening +webbrowser = "1.0" + +# File system watching +notify = "6.1" +notify-debouncer-full = "0.3" # Debounced file events + +# Search (ripgrep as library) +grep = "0.3" # ripgrep internals +walkdir = "2.4" + +# Date/time +chrono = { version = "0.4", features = ["serde"] } + +# Logging +tracing = "0.1" +tracing-subscriber = "0.3" +``` + +### Frontend (package.json) +```json +{ + "name": "ironpad-frontend", + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.5.0", + "pinia": "^2.3.0", + "codemirror": "^6.0.1", + "@codemirror/lang-markdown": "^6.3.2", + "markdown-it": "^14.1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^6.0.5", + "typescript": "^5.7.2" + } +} +``` + +--- + +## 16. Open Questions & Decisions Tracking + +| Question | Decision | Rationale | Date | +|----------|----------|-----------|------| +| Electron vs Rust backend? | **Rust backend** | Smaller binary, no bundled browser | 2026-02-02 | +| Auto-save strategy? | **2s debounce** | Balance safety and performance | 2026-02-02 | +| Git commit strategy? | **5min batch + manual** | Clean history, user control | 2026-02-02 | +| Editor library? | **CodeMirror 6** | Mature, performant, good UX | 2026-02-02 | +| Search in v1? | **Yes (ripgrep-based)** | Fast, proven, <100ms target | 2026-02-02 | +| Tasks per project or global? | **Per project** | Cleaner mental model | 2026-02-02 | +| Mobile access? | **Not v1 priority** | Desktop-first | 2026-02-02 | +| Port conflict handling? | **Dynamic 3000-3010** | Always works, graceful fallback | 2026-02-02 | +| External edit support? | **Yes (notify + WebSocket)** | True local-first philosophy | 2026-02-02 | +| Frontmatter management? | **Auto-managed by backend** | Low ceremony, no manual IDs | 2026-02-02 | +| Task View vs Editor conflict? | **File locking** | Prevent race conditions | 2026-02-02 | +| Markdown standard? | **CommonMark** | Consistency backend/frontend | 2026-02-02 | + +--- + +## 17. Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Rust learning curve | High | Medium | Start simple, use Axum (easier than Actix) | +| Git conflicts (concurrent edits) | Low | Medium | Detect `.git/index.lock`, show UI warnings | +| Search performance at scale | Low | Medium | Ripgrep handles 5000+ files easily | +| Browser doesn't auto-open | Low | Low | Print URL as fallback | +| File corruption | Low | High | Git versioning protects against data loss | +| **WebSocket connection drops** | Medium | Medium | Auto-reconnect in frontend with exponential backoff | +| **File watcher overhead** | Low | Low | Debounce events (100ms), ignore .git/ | +| **Port conflicts** | Low | Low | Dynamic port selection 3000-3010 | +| **Race condition (Task View + Editor)** | Medium | High | File locking prevents simultaneous edits | +| **Markdown rendering mismatch** | Low | Medium | Use CommonMark everywhere | + +--- + +## 18. Future Enhancements (Out of Scope) + +### Potential v2+ Features +- **Global hotkey** - Ctrl+Shift+Space to bring app to front (using `global-hotkey` crate) +- **System tray icon** - Keep app running in background (using `tray-icon` crate) +- **Backlinks** - Automatic link detection between notes +- **Graph view** - Visual representation of note connections +- **Rich editor** - WYSIWYG markdown editor +- **Templates** - Note templates (daily, meeting, project) +- **Plugins** - Extension system for custom functionality +- **Sync** - Optional cloud sync via Git remote +- **Themes** - Dark mode, custom color schemes +- **Export** - PDF, HTML, DOCX export +- **Mobile web UI** - Responsive design for mobile browsers +- **Kanban view** - Visual task board per project +- **Time tracking** - Track time spent on tasks +- **Voice notes** - Audio recording integration +- **OCR** - Extract text from images + +--- + +## 19. Addressing Gemini's Feedback + +### ✅ 1. Task Syncing Race Conditions +**Issue**: Task View checkboxes vs Editor text edits conflict +**Solution**: File locking system +- Task View locks `tasks.md` when open +- Editor shows "Read-Only" banner if file locked +- Only one view can edit at a time + +### ✅ 2. File System Watching +**Issue**: External edits (VS Code, Obsidian) don't sync +**Solution**: `notify` crate + WebSocket +- Backend watches `data/` directory +- Sends real-time updates to frontend +- UI shows "File changed externally. Reload?" banner + +### ✅ 3. Git Conflict Handling +**Issue**: `.git/index.lock` can cause crashes +**Solution**: Graceful lock detection +- Check for lock file before committing +- Skip auto-commit if locked, retry next cycle +- Show UI warning if git conflicts detected + +### ✅ 4. Frontmatter Management +**Issue**: Manual timestamp/ID editing is high-friction +**Solution**: Backend owns all frontmatter +- Auto-generate `id` from filename +- Auto-update `updated` on every save +- Users never manually edit metadata + +### ✅ 5. Port Conflicts +**Issue**: Hardcoded :3000 breaks if port busy +**Solution**: Dynamic port selection +- Try ports 3000-3010 +- Bind to first available +- Log actual port used + +### ✅ 6. Search Performance +**Issue**: Naive grep slow at >500 files +**Solution**: Use `ripgrep` library +- Battle-tested, used by VS Code +- Handles 5000+ files easily +- <100ms target achieved + +### ✅ 7. Markdown Consistency +**Issue**: Backend parsing vs frontend rendering mismatch +**Solution**: CommonMark everywhere +- Backend: `markdown-rs` (CommonMark mode) +- Frontend: `markdown-it` (CommonMark preset) +- Guaranteed consistency + +### ✅ 8. State Management +**Issue**: Pinia caching vs file system truth +**Solution**: Minimal caching philosophy +- File system is source of truth +- Pinia only caches current view +- WebSocket invalidates cache on external changes + +### ✅ 9. Asset Management +**Issue**: No image/file storage +**Solution**: `assets/` folders +- Each project gets `assets/` subfolder +- Global `notes/assets/` for shared files +- Upload via `/api/assets/upload` + +--- + +## 20. Conclusion + +**Ironpad v3.0** represents a robust, production-ready architecture: +- **Local-first** with true external editor support +- **Lightweight** Rust backend (no browser bundling) +- **Real-time** sync via WebSocket +- **Conflict-free** via file locking +- **Low-ceremony** via automatic frontmatter management +- **Resilient** via git lock handling and dynamic ports + +The system is designed to be: +- ✅ Easy to use daily +- ✅ Easy to understand and modify +- ✅ Easy to back up and migrate +- ✅ Fast and responsive +- ✅ A practical Rust learning project +- ✅ **Robust against real-world edge cases** + +**Next Step**: Begin Phase 1 implementation - Rust backend with dynamic port selection + automatic frontmatter. + +--- + +**Document Version History** +- v1.0 (2026-02-01): Initial draft with general architecture +- v2.0 (2026-02-02): Complete rewrite with Rust backend, browser-based frontend, detailed technical decisions +- v3.0 (2026-02-02): Addressed concurrency, file watching, git conflicts, port handling, frontmatter automation, and Gemini's architectural feedback + +**Contact**: Internal project - personal use \ No newline at end of file diff --git a/docs/ai-workflow/README.md b/docs/ai-workflow/README.md new file mode 100644 index 0000000..3399688 --- /dev/null +++ b/docs/ai-workflow/README.md @@ -0,0 +1,22 @@ +# AI-Assisted Development: How Ironpad Was Built + +Ironpad was built entirely using AI-assisted development. Not just the code -- the architecture, the PRD, the task breakdowns, and the debugging were all done through structured collaboration with AI models. + +We share this process openly as part of the **Open Method** philosophy: not just open source code, but open development process. + +## What's in This Folder + +| Document | Description | +|----------|-------------| +| [method.md](method.md) | The complete AI development workflow: PRD-first, task decomposition, handover system | +| [tools.md](tools.md) | Every tool used, how it fits in, and why | +| [lessons-learned.md](lessons-learned.md) | What worked, what didn't, and the 200K to 1M context window shift | + +## Articles + +- [The AI Development Workflow I Actually Use](https://dev.to/olaproeis/the-ai-development-workflow-i-actually-use-549i) -- The original workflow article describing the method +- [From 200K to 1M: How Claude Opus 4.6 Changed My AI Development Workflow](docs/article-opus-4.6-workflow.md) -- Follow-up on the context window leap + +## Key Takeaway + +The tools keep getting better, but **the process of using them well still matters**. A 1 million token context window doesn't help if you don't know what to ask for. Structure your work, be specific, maintain context, verify results. diff --git a/docs/ai-workflow/lessons-learned.md b/docs/ai-workflow/lessons-learned.md new file mode 100644 index 0000000..691fc3a --- /dev/null +++ b/docs/ai-workflow/lessons-learned.md @@ -0,0 +1,133 @@ +# Lessons Learned + +What worked, what didn't, and what changed when the context window went from 200K to 1M tokens. + +## What Worked Well + +### 1. PRD-First Development + +Writing a detailed PRD before any code was the single highest-leverage activity. The AI produces dramatically better code when it knows exactly what success looks like. + +Ironpad's PRD went through 3 versions. Each round of multi-AI review caught issues that would have been expensive to fix later: +- Concurrent editing race conditions (file locking needed) +- File watcher integration (external editor support) +- Git conflict handling (graceful degradation, not crashes) +- Frontmatter automation (users shouldn't manually edit metadata) + +**Lesson:** Time spent on the PRD pays off 10x during implementation. + +### 2. Rust's Strict Compiler + +Rust stands out for AI-assisted development because the compiler is extraordinarily strict. It catches: +- Memory safety issues +- Type mismatches +- Lifetime problems +- Unused variables and imports +- Missing error handling + +With dynamic languages, bugs hide until runtime. With Rust, the AI gets immediate, precise feedback on what's broken. The feedback loop is tighter and more reliable. + +`cargo check` became the primary verification tool. If it compiles, a large category of bugs is already eliminated. + +**Lesson:** A strict compiler is a massive advantage for AI-generated code. + +### 3. The ai-context.md Pattern + +Maintaining a lean (~100 line) architectural reference that tells the AI how to write code for this specific project eliminated a whole class of "doesn't fit the codebase" problems. + +Without it, the AI would invent new patterns, use different naming conventions, or structure code differently from the existing codebase. With it, code consistently matched existing patterns. + +**Lesson:** A small context document is worth more than a large architecture doc. The AI needs a cheat sheet, not a textbook. + +### 4. Fresh Chats Over Long Conversations + +Context accumulates noise. By the third task in a single chat, the AI references irrelevant earlier context. Starting fresh with a focused handover produced consistently better results. + +**Lesson:** Shorter, focused sessions beat long wandering ones. + +## What Didn't Work + +### 1. Trusting "This Should Work" + +The AI confidently says "this should work" when it doesn't. Every single time. Without exception. + +Early on, I'd take the AI's word and move on. Then things would break two features later when the untested code interacted with something else. + +**Fix:** Test everything yourself. Run the feature. Click the buttons. Try the edge cases. The AI writes code; you verify the product. + +### 2. Vague Requirements + +"Add search" produces mediocre results. "Add full-text search with ripgrep, triggered by Ctrl+K, showing filename and matching line with context, limited to 5 matches per file, falling back to manual string search if ripgrep isn't available" produces excellent results. + +**Fix:** Be specific. The more precise the requirement, the better the code. + +### 3. Over-Engineering + +The AI tends to add abstractions, patterns, and generalization that aren't needed yet. It builds for a future that may never come. + +**Fix:** Explicitly state YAGNI in the context. Call out when something is over-engineered. The AI responds well to "simplify this." + +### 4. Ignoring the Editor Lifecycle + +The Milkdown WYSIWYG editor had a complex initialization lifecycle that the AI didn't fully understand. This caused a critical bug where switching between notes showed stale content, leading to data loss. + +**Fix:** Document critical component lifecycles in ai-context.md. The "Milkdown Editor Lifecycle" section was added after this bug and prevented similar issues. + +## The 200K to 1M Context Shift + +This was the most significant change in the project's development workflow. + +### Before: 200K Tokens (Claude Opus 4.5) + +| Aspect | Reality | +|--------|---------| +| Files in context | ~3-5 at once | +| Task granularity | Must split features into 3-5 micro-tasks | +| Handovers | Required between every task | +| Cross-file bugs | Hard to find (AI can't see all files) | +| Refactors | Multi-session, risk of inconsistency | +| Overhead per task | ~15-20 min (handover + context setup) | + +### After: 1M Tokens (Claude Opus 4.6) + +| Aspect | Reality | +|--------|---------| +| Files in context | Entire codebase (80+ files) | +| Task granularity | Full features in one session | +| Handovers | Only needed between days/sessions | +| Cross-file bugs | Found automatically (AI sees everything) | +| Refactors | Single session, guaranteed consistency | +| Overhead per task | ~0 min | + +### The Codebase Audit + +The clearest demonstration of the shift: loading the entire Ironpad codebase into a single context and asking "what's wrong?" + +The AI found 16 issues, including: +- **Auto-commit silently broken** -- A flag that was never set to `true` anywhere in the codebase. Finding this required reading `main.rs`, `git.rs`, and every route handler simultaneously. +- **Operator precedence bug** -- `0 > 0` evaluated before `??` due to JavaScript precedence rules. Subtle and easy to miss. +- **Missing atomic writes** -- Only one of eight write paths used the safe atomic write pattern. + +14 of 16 issues were fixed in a single session. Zero compilation errors introduced. + +This type of comprehensive audit was not practical at 200K tokens. + +### What Didn't Change + +The 1M context window doesn't change everything: +- **PRDs are still essential.** More context doesn't substitute for clear requirements. +- **Testing is still essential.** The AI still says "this should work" when it doesn't. +- **Specificity still matters.** Vague asks still produce vague results. +- **Handovers still matter** between sessions (sleep, context switches, etc.) + +The context window is a force multiplier, not a replacement for process. + +## Advice for Others + +1. **Start with the PRD.** It's the highest-leverage activity. +2. **Use a strict language if you can.** Rust, TypeScript (strict mode), Go -- anything with a compiler that catches bugs. +3. **Maintain ai-context.md.** Keep it under 100 lines. Update it when patterns change. +4. **Test everything.** Don't read code. Run the thing. +5. **Use multiple AI models.** They have different blind spots. +6. **Be specific.** The more precise the requirement, the better the result. +7. **Keep sessions focused.** One task, one chat (at 200K). One feature, one chat (at 1M). diff --git a/docs/ai-workflow/method.md b/docs/ai-workflow/method.md new file mode 100644 index 0000000..2b7d18d --- /dev/null +++ b/docs/ai-workflow/method.md @@ -0,0 +1,119 @@ +# The Development Method + +This document describes the AI-assisted development workflow used to build Ironpad from scratch. + +## Overview + +The method has six phases, applied iteratively for each feature: + +1. **Multi-AI Consultation** -- Get perspectives from multiple AI models before coding +2. **PRD Creation** -- Write a detailed product requirements document +3. **Task Decomposition** -- Break the PRD into ordered, dependency-aware tasks +4. **Context Loading** -- Feed the AI current documentation and project context +5. **Implementation** -- Build features in focused sessions with handovers +6. **Verification** -- Test everything yourself; don't trust "this should work" + +## Phase 1: Multi-AI Consultation + +Before writing any code, discuss the idea with different AI assistants: + +- **Claude** for architecture and code design +- **Perplexity** for research on libraries, crate versions, and known issues +- **Gemini** for alternative perspectives and catching blind spots + +Each AI has different strengths and blind spots. Five minutes getting multiple opinions saves hours of rework later. + +**Example from Ironpad:** When designing the task system, one model suggested storing tasks as checkboxes in a single `tasks.md` file. Another pointed out that individual task files with frontmatter would be more flexible and avoid concurrent edit conflicts. We went with individual files, which turned out to be the right call. + +## Phase 2: PRD Creation + +Task Master (and AI in general) produces dramatically better results when it knows exactly what success looks like. The PRD captures: + +- Problem statement and goals +- Detailed feature specifications +- Technical architecture decisions +- API design +- Data model +- Edge cases and error handling +- Non-goals (equally important) + +After drafting, run the PRD through other AIs for review. Iterate until it's tight. + +**Ironpad's PRD** went through 3 versions, incorporating feedback about concurrency control, file watching, git conflict handling, and frontmatter automation -- all before a single line of code was written. + +## Phase 3: Task Decomposition + +Use Task Master to parse the PRD into structured tasks with dependencies. Each task should have: + +- Clear inputs (what files/context are needed) +- Clear outputs (what gets created/changed) +- Explicit dependencies (what must be done first) +- Acceptance criteria (how to verify it works) + +You don't need Task Master specifically. What matters is having explicit, ordered tasks rather than vague goals like "add search." + +## Phase 4: Context Loading + +AI models have training cutoffs. The library docs they know might be outdated. + +- **Context7** (MCP tool) pulls current documentation into context for any library +- **ai-context.md** is a lean architectural reference (~100 lines) telling the AI how to write code that fits the codebase +- **Handover documents** carry context between sessions + +### The ai-context.md Pattern + +This file tells the AI *how* to write code that belongs in this project: + +- Module structure and naming conventions +- Key types and their relationships +- Framework idioms (Axum patterns, Vue composition API patterns) +- Critical gotchas that cause bugs (e.g., Milkdown editor lifecycle) +- Current implementation status + +It's not a full architecture doc. It's a cheat sheet for the AI. + +## Phase 5: Implementation + +### The Handover System (200K Context) + +With 200K token models, each task gets a fresh chat: + +1. Open new chat +2. Paste handover document with: rules, relevant files, current task +3. Work on task until done +4. AI updates the handover document for the next task +5. Close chat, repeat + +**Why fresh chats?** Context accumulates noise. Three tasks in, the AI references irrelevant stuff from earlier. Starting clean with a focused handover produces better results. + +### The Full-Context Approach (1M Context) + +With 1M token models (Claude Opus 4.6), the workflow simplifies: + +1. Load the entire codebase into context +2. Work on features directly -- the AI sees everything +3. Use handovers only for session boundaries (end of day, sleep, etc.) + +The handover system doesn't disappear -- it shifts from "required between every task" to "useful between sessions." + +## Phase 6: Verification + +The AI writes the code. You verify the product. + +- Run the feature and see if it works +- Test edge cases manually +- Check that nothing else broke +- Use compiler output (`cargo check`) and linters as mechanical verification + +Don't read code line by line. Run the thing and see if it works. When something's wrong, describe the problem clearly. The AI debugs from there. + +## How This Played Out for Ironpad + +| Phase | What Happened | +|-------|---------------| +| 1-3 | PRD v3.0 with architecture decisions, reviewed by multiple AIs | +| 4 | ai-context.md maintained throughout, Context7 for Axum/Vue/Milkdown docs | +| 5 | Phases 1-3 built with Opus 4.5 (200K), phases 4-5 with Opus 4.6 (1M) | +| 6 | Every feature manually tested in the browser | + +Total development time: approximately 2 weeks from PRD to working application with dashboard, calendar, git panel, WYSIWYG editor, subtasks, recurring tasks, and real-time sync. diff --git a/docs/ai-workflow/tools.md b/docs/ai-workflow/tools.md new file mode 100644 index 0000000..6835fb1 --- /dev/null +++ b/docs/ai-workflow/tools.md @@ -0,0 +1,116 @@ +# Tools Used + +Every tool in the Ironpad development workflow and how it fits in. + +## IDE + +### Cursor IDE + +The primary development environment. Cursor is a fork of VS Code with built-in AI integration. + +- **Why:** Direct integration with Claude models, inline code editing, multi-file context +- **How used:** All coding, file editing, terminal commands, and AI conversations happen in Cursor +- **Alternative:** VS Code + Copilot, Windsurf, or any editor with AI integration + +## AI Models + +### Claude Opus 4.5 (Anthropic) + +Used for the majority of Ironpad's development (Phases 1-3 of the implementation). + +- **Context window:** 200K tokens +- **Strengths:** Excellent at Rust code, understands Axum patterns well, good at architecture +- **Limitation:** Can only hold ~5 files at once; required task splitting and handover documents +- **How used:** Feature implementation, debugging, code review + +### Claude Opus 4.6 (Anthropic) + +Used for later phases and the full codebase audit. + +- **Context window:** 1M tokens (5x increase) +- **Strengths:** Can hold the entire codebase at once; finds cross-file bugs; handles complex refactors in one session +- **How used:** Codebase audit (found 16 issues), cross-cutting refactors, feature implementation without task splitting + +### Perplexity AI + +Used for research before coding. + +- **Why:** Has internet access, provides current information with citations +- **How used:** Checking library versions, finding known issues with crates, researching approaches +- **Example:** Verified that `serde_yaml` was deprecated before we chose to use it (accepted risk for v1) + +### Google Gemini + +Used as a second opinion on architecture. + +- **Why:** Different training data and perspective from Claude +- **How used:** PRD review, architecture review, catching blind spots +- **Example:** Flagged the need for file locking between Task View and Editor (race condition that Claude's initial design missed) + +## MCP Tools + +### Task Master + +A Model Context Protocol (MCP) tool for structured task management. + +- **What it does:** Parses a PRD and generates ordered task lists with dependencies +- **How used:** Fed the PRD to Task Master to generate the implementation plan; tasks tracked through completion +- **Why it matters:** Turns a document into actionable, sequenced work items + +### Context7 + +A Model Context Protocol (MCP) tool for pulling current library documentation. + +- **What it does:** Fetches up-to-date documentation for any library and loads it into the AI's context +- **How used:** Pulled current Axum 0.8 docs, Vue 3 Composition API docs, Milkdown editor API docs +- **Why it matters:** Eliminates bugs caused by the AI using outdated API knowledge + +## Build Tools + +### Rust / Cargo + +- Backend language and build system +- `cargo check` for fast compilation checks +- `cargo build --release` for production binaries +- Strict compiler catches entire categories of bugs before runtime + +### Node.js / Vite + +- Frontend build tooling +- `npm run dev` for development with hot reload +- `npm run build` for production static files +- Vue SFC compilation via `@vitejs/plugin-vue` + +### Git + +- Version control for all data files +- `git2` crate for programmatic access from Rust +- Automatic 60-second commit batching +- Full diff viewer in the UI + +## The Tool Stack in Practice + +``` +Idea + | + v +[Perplexity] -- Research libraries, check feasibility +[Gemini] -- Second opinion on approach + | + v +[Claude] -- Draft PRD +[Gemini] -- Review PRD + | + v +[Task Master] -- Generate ordered task list + | + v +[Cursor + Claude] -- Implement each task +[Context7] -- Current docs when needed + | + v +[Manual Testing] -- Verify in browser +[cargo check] -- Compiler verification +``` + +No single tool does everything. The value is in how they compose. diff --git a/docs/screenshot.jpg b/docs/screenshot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8c6605bf34cdb2e31cee4ff7dad85bcb88c3cad8 GIT binary patch literal 218880 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%0AjR}^Gq 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

W7^7~r1?{`^8~B(5LC(l@k#F)9;1WCil8f|{ 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 literal 0 HcmV?d00001 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..6d11cd7 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,143 @@ +# Ironpad Frontend + +Vue 3 single-page application for Ironpad. + +## Quick Start + +```bash +npm install +npm run dev +``` + +Open http://localhost:5173 (requires backend running on port 3000). + +## Tech Stack + +- **Vue 3** with Composition API and ` + + + +

+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..26c3d61 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4025 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@codemirror/commands": "^6.10.1", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.1", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.12", + "@milkdown/crepe": "^7.18.0", + "@milkdown/kit": "^7.18.0", + "@milkdown/vue": "^7.18.0", + "@prosemirror-adapter/vue": "^0.4.6", + "codemirror": "^6.0.2", + "markdown-it": "^14.1.0", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/node": "^22.15.0", + "@vitejs/plugin-vue": "^5.2.3", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.7.3", + "vite": "^6.3.5", + "vue-tsc": "^2.2.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz", + "integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-angular": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", + "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.3" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-jinja": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.0.tgz", + "integrity": "sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.1.tgz", + "integrity": "sha512-S/jE/D7iij2Pu70AC65ME6AYWxOOcX20cSJvaPgY5w7m2sfxsArAcUAuUgm/CZCVmqoi9KiOlS7gj/gyLipABw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-wast": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", + "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", + "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", + "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/language-data": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", + "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==", + "dependencies": { + "@codemirror/lang-angular": "^0.1.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-jinja": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-less": "^6.0.0", + "@codemirror/lang-liquid": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/lang-sass": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-vue": "^0.1.1", + "@codemirror/lang-wast": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.4.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.3.tgz", + "integrity": "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", + "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.39.12", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.12.tgz", + "integrity": "sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ==", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz", + "integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", + "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==" + }, + "node_modules/@milkdown/components": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/components/-/components-7.18.0.tgz", + "integrity": "sha512-Zu/GMqy1byyxul/+/RWcpe02b7luhtW1SfTYNFZnaWPvIap5M9vG7pFeQNRqJe5cbfKI+bvW8Ubyb5BG2kb9Ug==", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/exception": "7.18.0", + "@milkdown/plugin-tooltip": "7.18.0", + "@milkdown/preset-commonmark": "7.18.0", + "@milkdown/preset-gfm": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/transformer": "7.18.0", + "@milkdown/utils": "7.18.0", + "@types/lodash-es": "^4.17.12", + "clsx": "^2.0.0", + "dompurify": "^3.2.5", + "lodash-es": "^4.17.21", + "nanoid": "^5.0.9", + "unist-util-visit": "^5.0.0", + "vue": "^3.5.20" + }, + "peerDependencies": { + "@codemirror/language": "^6", + "@codemirror/state": "^6", + "@codemirror/view": "^6" + } + }, + "node_modules/@milkdown/core": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/core/-/core-7.18.0.tgz", + "integrity": "sha512-BUVR/72XwrtM3qHTTtXtmCtGfuaAexvSxosYIXw7d6ElbLiLIe3bOXjGwwgLHW3xsq23VKmYMsFqWLUFt6uGDQ==", + "dependencies": { + "@milkdown/ctx": "7.18.0", + "@milkdown/exception": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/transformer": "7.18.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.3" + } + }, + "node_modules/@milkdown/crepe": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/crepe/-/crepe-7.18.0.tgz", + "integrity": "sha512-GcHW6Use0MCRvFg6RQVN5EaeyMlxFxDEGbGwqApnBblxZi5PV9nlAAn0AfOhYvFHSDkQ3rQa5fuHQ0Bd0KobQQ==", + "dependencies": { + "@codemirror/commands": "^6.2.4", + "@codemirror/language": "^6.10.1", + "@codemirror/language-data": "^6.3.1", + "@codemirror/state": "^6.4.1", + "@codemirror/theme-one-dark": "^6.1.2", + "@codemirror/view": "^6.16.0", + "@milkdown/kit": "7.18.0", + "@types/lodash-es": "^4.17.12", + "clsx": "^2.0.0", + "codemirror": "^6.0.1", + "katex": "^0.16.0", + "lodash-es": "^4.17.21", + "prosemirror-virtual-cursor": "^0.4.2", + "remark-math": "^6.0.0", + "unist-util-visit": "^5.0.0", + "vue": "^3.5.20" + } + }, + "node_modules/@milkdown/ctx": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/ctx/-/ctx-7.18.0.tgz", + "integrity": "sha512-F+t8U/akpY7Vw+KD+z32Itr6lrVLAGTVO79DN436BnFK/J9kiPzTRfTet6fMOj3NlwO/24lUluiPZd7qbCmn8A==", + "dependencies": { + "@milkdown/exception": "7.18.0" + } + }, + "node_modules/@milkdown/exception": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/exception/-/exception-7.18.0.tgz", + "integrity": "sha512-sAyi4IqdChh4+lpgucmgDZNGjYuIRvJimZeMj0SdfdeHDABan5Nco3X+5yOGaBq1z9QOJG90+vEcEvUASHBmFw==" + }, + "node_modules/@milkdown/kit": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/kit/-/kit-7.18.0.tgz", + "integrity": "sha512-6C8c/bU+3Md/rlZFTqMmdVen2xSC80LYBOZ/G4+W39gsV7x/ux/HRdd8xk75a4IrHKgq6EJpGJ1yH8BvT7P+1A==", + "dependencies": { + "@milkdown/components": "7.18.0", + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/plugin-block": "7.18.0", + "@milkdown/plugin-clipboard": "7.18.0", + "@milkdown/plugin-cursor": "7.18.0", + "@milkdown/plugin-history": "7.18.0", + "@milkdown/plugin-indent": "7.18.0", + "@milkdown/plugin-listener": "7.18.0", + "@milkdown/plugin-slash": "7.18.0", + "@milkdown/plugin-tooltip": "7.18.0", + "@milkdown/plugin-trailing": "7.18.0", + "@milkdown/plugin-upload": "7.18.0", + "@milkdown/preset-commonmark": "7.18.0", + "@milkdown/preset-gfm": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/transformer": "7.18.0", + "@milkdown/utils": "7.18.0" + } + }, + "node_modules/@milkdown/plugin-block": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-block/-/plugin-block-7.18.0.tgz", + "integrity": "sha512-+x00o7Vh5nQesw4j6QwtwCThdjSiH/jUvAzrTpwr8xvRmQnmztdfdJhPHxp48pK/sIEct3660HWuwDpdeAlmRw==", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@milkdown/plugin-clipboard": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-clipboard/-/plugin-clipboard-7.18.0.tgz", + "integrity": "sha512-Gnp+GqkoLS1pKG9S2QfdvZQjfoJosQek5Yv5zOIj5X388yfVlguKNtCwnDCJKVEVws9e8PnhfPBmzr06713dZw==", + "dependencies": { + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0" + } + }, + "node_modules/@milkdown/plugin-cursor": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-cursor/-/plugin-cursor-7.18.0.tgz", + "integrity": "sha512-SsvFEeFMv1jrzVBnuAMyAwZzhjwCk4wmGjJEug41Ic+CT0YMUtVPJn5QVn7fjixR13kzkfaNDUPZ+sGNqIR2xw==", + "dependencies": { + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0", + "prosemirror-drop-indicator": "^0.1.0" + } + }, + "node_modules/@milkdown/plugin-history": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-history/-/plugin-history-7.18.0.tgz", + "integrity": "sha512-hWM3rpad/THy267dXgEWRu9Arf+3j2KE8UN3jhqsUvVLZZ2ZetaPc2imHowJaLR8PwCb649+1RxL+IKrXizNKQ==", + "dependencies": { + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0" + } + }, + "node_modules/@milkdown/plugin-indent": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-indent/-/plugin-indent-7.18.0.tgz", + "integrity": "sha512-LAVMSsy6lWvy/QjvSazojUeW6v1lLFj5Fjv3YvqDNtP6/RSOIhHJs75aXbv92Kx43aRJnkh7EVy9Wu4OxSC70Q==", + "dependencies": { + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0" + } + }, + "node_modules/@milkdown/plugin-listener": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-listener/-/plugin-listener-7.18.0.tgz", + "integrity": "sha512-F2iPKdWYGJX5kMnmIeZeybQ5gZUwT/smNBbt/itPBn5cD4YRF1qmY/MxDs0+nvoN2NSxtEx5pHOtd5/E4mCf2A==", + "dependencies": { + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@milkdown/plugin-slash": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-slash/-/plugin-slash-7.18.0.tgz", + "integrity": "sha512-jBcaLswX1yKG97s0V1qFqk/0aR+LpWnTCHIrryNVRIRFYm7B6tITekkqwALlV2bqE1eykeN2j8yEyRQ63Wv05Q==", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@milkdown/plugin-tooltip": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-tooltip/-/plugin-tooltip-7.18.0.tgz", + "integrity": "sha512-Z8WYSEFANhHPS2A8uMIcKGJ3vt0KKCJ80hffuJffudJT9FSIXieh1f8OKcKQuhcRHxRCRUApMcOOjOptiVaHvQ==", + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@milkdown/plugin-trailing": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-trailing/-/plugin-trailing-7.18.0.tgz", + "integrity": "sha512-AusCWoZSRfgsStdlmg+4sYZ08HLDDiHhesDCqiLCdo1bklNhzK/9q6gxdL1HP5xTn5a4xV9hUrI7E7M0JaKdug==", + "dependencies": { + "@milkdown/ctx": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0" + } + }, + "node_modules/@milkdown/plugin-upload": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-upload/-/plugin-upload-7.18.0.tgz", + "integrity": "sha512-fsWwd6g6FX35Wg12KVE1Yu3wU8vM5hA567DufeHcik9LckdLJcZKf35JMJDUOAOkEdU3V91BKO47KUhBPFt1jA==", + "dependencies": { + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/exception": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/utils": "7.18.0" + } + }, + "node_modules/@milkdown/preset-commonmark": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/preset-commonmark/-/preset-commonmark-7.18.0.tgz", + "integrity": "sha512-L/F9vmhQKOjKJZTEEsKjDu/2KkMTDxBVQISk4w+j8KFWx9OpHBwqWqyHiDLTREbT7pJqLfyB96eXvfuMG4za5g==", + "dependencies": { + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/exception": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/transformer": "7.18.0", + "@milkdown/utils": "7.18.0", + "remark-inline-links": "^7.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@milkdown/preset-gfm": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/preset-gfm/-/preset-gfm-7.18.0.tgz", + "integrity": "sha512-NLfkd7HOaaMCMImXmBh8TX8KNkgKecM7YRHFEwb5D/SMLyBLyZs7lDfLEKPU9N52+vzgwMz8ceUSlCElmneTJg==", + "dependencies": { + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/exception": "7.18.0", + "@milkdown/preset-commonmark": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/transformer": "7.18.0", + "@milkdown/utils": "7.18.0", + "prosemirror-safari-ime-span": "^1.0.1", + "remark-gfm": "^4.0.1" + } + }, + "node_modules/@milkdown/prose": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/prose/-/prose-7.18.0.tgz", + "integrity": "sha512-bRDfgVM6uKRaejvju/FWdQMryQc4kSSso+fnABUbvbCKitXnsgRPvclsddbt3J92anQwLRDWr/qotx1NcyDM1Q==", + "dependencies": { + "@milkdown/exception": "7.18.0", + "prosemirror-changeset": "^2.3.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.0", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.1", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.3" + } + }, + "node_modules/@milkdown/transformer": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/transformer/-/transformer-7.18.0.tgz", + "integrity": "sha512-AzTgqDktQw9nzgrpICjYNxScYwwnxmALPSyZ39Y0wNZJafi8QMVqLv4w2bhyYkxITXolPHdLAAsZXPKuMjrmNA==", + "dependencies": { + "@milkdown/exception": "7.18.0", + "@milkdown/prose": "7.18.0", + "remark": "^15.0.1", + "unified": "^11.0.3" + } + }, + "node_modules/@milkdown/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-+o/1sky+QwbS0Y92HthTupMFziJKhZUgF7IBS55Ft4Wjt63kX8PHaLC9KtewNawpzyM/CjPJ9ySCIa+C/06Bsg==", + "dependencies": { + "@milkdown/core": "7.18.0", + "@milkdown/ctx": "7.18.0", + "@milkdown/exception": "7.18.0", + "@milkdown/prose": "7.18.0", + "@milkdown/transformer": "7.18.0", + "nanoid": "^5.0.9" + } + }, + "node_modules/@milkdown/vue": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@milkdown/vue/-/vue-7.18.0.tgz", + "integrity": "sha512-Cc0pHDCJXzJcwxTsv1kxfzx4oGxJIRt0Qw7POGHP6ec+C52+QMBRMC4A742nc/VchbU4Kzzh1O8NSHNeb9ocJA==", + "dependencies": { + "@milkdown/crepe": "7.18.0", + "@milkdown/kit": "7.18.0" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/@ocavue/utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ocavue/utils/-/utils-1.4.0.tgz", + "integrity": "sha512-hB6uTz58shfG/ZyrBkMjub2YyJ1ue0vE14zRLZWJl/zEcgMBFTX6nBBV6ncyRHK4qOIBwFNpXzZrjTFj1ofxRA==", + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/@prosemirror-adapter/core": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@prosemirror-adapter/core/-/core-0.4.6.tgz", + "integrity": "sha512-qbqd4u9IyDXXolFFmX1tzE000IpF9JbCiiwuSvqIh7lT9ZqjeMcyDVR/vxyM4oVY1sVyQQQj+WxHMAIhj7egVg==", + "dependencies": { + "prosemirror-model": "^1.25.3", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.41.1" + } + }, + "node_modules/@prosemirror-adapter/vue": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@prosemirror-adapter/vue/-/vue-0.4.6.tgz", + "integrity": "sha512-1zDxyi4JqH4Iw8SPAvkNSAfUPEHQDRi0Oshi2qOmRf3xFHk1hBiV8CL0kEmOUE2C40ipk9AWkX2uu3UgO/8IYw==", + "dependencies": { + "@prosemirror-adapter/core": "0.4.6", + "nanoid": "^5.1.6", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.3", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.41.1" + }, + "peerDependencies": { + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "node_modules/@types/node": { + "version": "22.19.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", + "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/katex": { + "version": "0.16.28", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", + "integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true + }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", + "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-drop-indicator": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/prosemirror-drop-indicator/-/prosemirror-drop-indicator-0.1.3.tgz", + "integrity": "sha512-fJV6G2tHIVXZLUuc60fS9ly1/GuGOlAZUm67S1El+kGFUYh27Hyv6hcGx3rrJ+Q/JZL5jnyAibIZYYWpPqE45g==", + "dependencies": { + "@ocavue/utils": "^1.0.0", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.41.3" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", + "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-safari-ime-span": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/prosemirror-safari-ime-span/-/prosemirror-safari-ime-span-1.0.2.tgz", + "integrity": "sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==", + "dependencies": { + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.33.8" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", + "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz", + "integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/prosemirror-virtual-cursor": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-virtual-cursor/-/prosemirror-virtual-cursor-0.4.2.tgz", + "integrity": "sha512-pUMKnIuOhhnMcgIJUjhIQTVJruBEGxfMBVQSrK0g2qhGPDm1i12KdsVaFw15dYk+29tZcxjMeR7P5VDKwmbwJg==", + "funding": { + "url": "https://github.com/sponsors/ocavue" + }, + "peerDependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + }, + "peerDependenciesMeta": { + "prosemirror-model": { + "optional": true + }, + "prosemirror-state": { + "optional": true + }, + "prosemirror-view": { + "optional": true + } + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/remark": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", + "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", + "dependencies": { + "@types/mdast": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-inline-links": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/remark-inline-links/-/remark-inline-links-7.0.0.tgz", + "integrity": "sha512-4uj1pPM+F495ySZhTIB6ay2oSkTsKgmYaKk/q5HIdhX2fuyLEegpjWa0VdJRJ01sgOqAFo7MBKdDUejIYBMVMQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-definitions": "^6.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9950b30 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/commands": "^6.10.1", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.1", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.12", + "@milkdown/crepe": "^7.18.0", + "@milkdown/kit": "^7.18.0", + "@milkdown/vue": "^7.18.0", + "@prosemirror-adapter/vue": "^0.4.6", + "codemirror": "^6.0.2", + "markdown-it": "^14.1.0", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.2", + "@types/node": "^22.15.0", + "@vitejs/plugin-vue": "^5.2.3", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.7.3", + "vite": "^6.3.5", + "vue-tsc": "^2.2.10" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..1460fdc --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..f6cafa5 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,240 @@ +// API client for Ironpad backend + +import type { + Note, + NoteSummary, + Project, + ProjectWithContent, + ProjectNote, + ProjectNoteWithContent, + Task, + TaskWithContent, + SearchResult, + GitStatus, + CommitInfo, + CommitDetail, + DiffInfo, + RemoteInfo, + DailyNote +} from '../types' + +const API_BASE = '/api' + +async function request(url: string, options?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${url}`, options) + if (!res.ok) { + const text = await res.text() + throw new Error(text || `HTTP ${res.status}`) + } + // Handle empty responses + const contentType = res.headers.get('content-type') + if (contentType?.includes('application/json')) { + return res.json() + } + return undefined as T +} + +// Notes API +export const notesApi = { + list: () => request('/notes'), + + get: (id: string) => request(`/notes/${encodeURIComponent(id)}`), + + create: () => request('/notes', { method: 'POST' }), + + update: (id: string, content: string) => + request(`/notes/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: content + }), + + delete: (id: string) => + request(`/notes/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +// Projects API +export const projectsApi = { + list: () => request('/projects'), + + get: (id: string) => request(`/projects/${encodeURIComponent(id)}`), + + getContent: (id: string) => + request(`/projects/${encodeURIComponent(id)}/content`), + + updateContent: (id: string, content: string) => + request(`/projects/${encodeURIComponent(id)}/content`, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: content + }), + + create: (name: string) => + request('/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }), + + // Project Notes + listNotes: (projectId: string) => + request(`/projects/${encodeURIComponent(projectId)}/notes`), + + getNote: (projectId: string, noteId: string) => + request(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`), + + createNote: (projectId: string, title?: string) => + request(`/projects/${encodeURIComponent(projectId)}/notes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }) + }), + + updateNote: (projectId: string, noteId: string, content: string) => + request(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: content + }), + + deleteNote: (projectId: string, noteId: string) => + request(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`, { + method: 'DELETE' + }) +} + +// Tasks API (file-based tasks) +export const tasksApi = { + // List all tasks across all projects + listAll: () => request('/tasks'), + + // List tasks for a specific project + list: (projectId: string) => + request(`/projects/${encodeURIComponent(projectId)}/tasks`), + + // Get a single task with content + get: (projectId: string, taskId: string) => + request(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`), + + // Create a new task + create: (projectId: string, title: string, section?: string, parentId?: string) => + request(`/projects/${encodeURIComponent(projectId)}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, section, parent_id: parentId || undefined }) + }), + + // Update task content (markdown body) + updateContent: (projectId: string, taskId: string, content: string) => + request(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: content + }), + + // Toggle task completion + toggle: (projectId: string, taskId: string) => + request(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/toggle`, { + method: 'PUT' + }), + + // Update task metadata + updateMeta: (projectId: string, taskId: string, meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number }) => + request(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/meta`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(meta) + }), + + // Delete (archive) a task + delete: (projectId: string, taskId: string) => + request(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, { + method: 'DELETE' + }) +} + +// Search API +export const searchApi = { + search: (query: string) => + request(`/search?q=${encodeURIComponent(query)}`) +} + +// Git API +export const gitApi = { + status: () => request('/git/status'), + + commit: (message?: string) => + request('/git/commit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }) + }), + + push: () => request<{ success: boolean; message: string }>('/git/push', { method: 'POST' }), + + conflicts: () => request('/git/conflicts'), + + // Commit history + log: (limit?: number) => + request(`/git/log${limit ? `?limit=${limit}` : ''}`), + + // Working directory diff (uncommitted changes) + diff: () => request('/git/diff'), + + // Diff for a specific commit + commitDiff: (commitId: string) => + request(`/git/diff/${encodeURIComponent(commitId)}`), + + // Remote repository info + remote: () => request('/git/remote'), + + // Fetch from remote + fetch: () => request<{ success: boolean; message: string }>('/git/fetch', { method: 'POST' }) +} + +// Daily Notes API +export const dailyApi = { + list: () => request('/daily'), + + today: () => request('/daily/today'), + + get: (date: string) => request(`/daily/${date}`), + + create: (date: string, content?: string) => + request(`/daily/${date}`, { + method: 'POST', + headers: content ? { 'Content-Type': 'application/json' } : undefined, + body: content ? JSON.stringify({ content }) : undefined + }), + + update: (date: string, content: string) => + request(`/daily/${date}`, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: content + }) +} + +// Assets API +export const assetsApi = { + upload: async (file: File, projectId?: string): Promise<{ url: string; filename: string }> => { + const formData = new FormData() + formData.append('file', file) + + const params = projectId ? `?project=${encodeURIComponent(projectId)}` : '' + const res = await fetch(`${API_BASE}/assets/upload${params}`, { + method: 'POST', + body: formData + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(text || `HTTP ${res.status}`) + } + + return res.json() + }, + + getUrl: (project: string, filename: string) => + `${API_BASE}/assets/${encodeURIComponent(project)}/${encodeURIComponent(filename)}` +} diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/ConflictBanner.vue b/frontend/src/components/ConflictBanner.vue new file mode 100644 index 0000000..bbd5741 --- /dev/null +++ b/frontend/src/components/ConflictBanner.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/frontend/src/components/EditorToolbar.vue b/frontend/src/components/EditorToolbar.vue new file mode 100644 index 0000000..446c1ec --- /dev/null +++ b/frontend/src/components/EditorToolbar.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/src/components/GitPanel.vue b/frontend/src/components/GitPanel.vue new file mode 100644 index 0000000..07e128a --- /dev/null +++ b/frontend/src/components/GitPanel.vue @@ -0,0 +1,879 @@ + + + + + diff --git a/frontend/src/components/GitStatus.vue b/frontend/src/components/GitStatus.vue new file mode 100644 index 0000000..161c0d3 --- /dev/null +++ b/frontend/src/components/GitStatus.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/src/components/MarkdownEditor.vue b/frontend/src/components/MarkdownEditor.vue new file mode 100644 index 0000000..aa3a938 --- /dev/null +++ b/frontend/src/components/MarkdownEditor.vue @@ -0,0 +1,533 @@ + + + + + diff --git a/frontend/src/components/MarkdownPreview.vue b/frontend/src/components/MarkdownPreview.vue new file mode 100644 index 0000000..ece2516 --- /dev/null +++ b/frontend/src/components/MarkdownPreview.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/frontend/src/components/MilkdownEditor.vue b/frontend/src/components/MilkdownEditor.vue new file mode 100644 index 0000000..fb2f2c1 --- /dev/null +++ b/frontend/src/components/MilkdownEditor.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/components/MilkdownEditorCore.vue b/frontend/src/components/MilkdownEditorCore.vue new file mode 100644 index 0000000..399ce18 --- /dev/null +++ b/frontend/src/components/MilkdownEditorCore.vue @@ -0,0 +1,442 @@ + + + + + diff --git a/frontend/src/components/NoteList.vue b/frontend/src/components/NoteList.vue new file mode 100644 index 0000000..26e92ce --- /dev/null +++ b/frontend/src/components/NoteList.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/components/ProjectList.vue b/frontend/src/components/ProjectList.vue new file mode 100644 index 0000000..a905659 --- /dev/null +++ b/frontend/src/components/ProjectList.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/frontend/src/components/ReadOnlyBanner.vue b/frontend/src/components/ReadOnlyBanner.vue new file mode 100644 index 0000000..0d0e132 --- /dev/null +++ b/frontend/src/components/ReadOnlyBanner.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/frontend/src/components/SearchPanel.vue b/frontend/src/components/SearchPanel.vue new file mode 100644 index 0000000..1eae314 --- /dev/null +++ b/frontend/src/components/SearchPanel.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue new file mode 100644 index 0000000..caf77af --- /dev/null +++ b/frontend/src/components/Sidebar.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/frontend/src/components/TaskPanel.vue b/frontend/src/components/TaskPanel.vue new file mode 100644 index 0000000..cef38fb --- /dev/null +++ b/frontend/src/components/TaskPanel.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue new file mode 100644 index 0000000..2fe29c0 --- /dev/null +++ b/frontend/src/components/TopBar.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/frontend/src/composables/useWebSocket.ts b/frontend/src/composables/useWebSocket.ts new file mode 100644 index 0000000..5187017 --- /dev/null +++ b/frontend/src/composables/useWebSocket.ts @@ -0,0 +1,148 @@ +import { ref, onMounted, onUnmounted } from 'vue' +import type { WsMessage, WsConnectedPayload } from '../types' + +export interface UseWebSocketOptions { + onFileCreated?: (path: string) => void + onFileModified?: (path: string) => void + onFileDeleted?: (path: string) => void + onFileRenamed?: (from: string, to: string) => void + onFileLocked?: (path: string, clientId: string, lockType: string) => void + onFileUnlocked?: (path: string) => void + onGitConflict?: (files: string[]) => void +} + +export function useWebSocket(options: UseWebSocketOptions = {}) { + const connected = ref(false) + const clientId = ref(null) + let ws: WebSocket | null = null + let reconnectTimeout: number | null = null + let reconnectAttempts = 0 + const MAX_RECONNECT_DELAY = 30000 // 30 seconds max + + function connect() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const wsUrl = `${protocol}//${window.location.host}/ws` + + ws = new WebSocket(wsUrl) + + ws.onopen = () => { + connected.value = true + reconnectAttempts = 0 // Reset backoff on successful connection + console.log('WebSocket connected') + } + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) as WsMessage + handleMessage(msg) + } catch (e) { + console.error('Failed to parse WebSocket message:', e) + } + } + + ws.onclose = () => { + connected.value = false + clientId.value = null + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY) + reconnectAttempts++ + console.log(`WebSocket disconnected, reconnecting in ${delay / 1000}s...`) + reconnectTimeout = window.setTimeout(connect, delay) + } + + ws.onerror = (e) => { + console.error('WebSocket error:', e) + } + } + + function handleMessage(msg: WsMessage) { + switch (msg.type) { + case 'Connected': { + const payload = msg.payload as WsConnectedPayload + clientId.value = payload.client_id + console.log('WebSocket client ID:', payload.client_id) + break + } + case 'FileCreated': { + const payload = msg.payload as { path: string } + options.onFileCreated?.(payload.path) + break + } + case 'FileModified': { + const payload = msg.payload as { path: string } + options.onFileModified?.(payload.path) + break + } + case 'FileDeleted': { + const payload = msg.payload as { path: string } + options.onFileDeleted?.(payload.path) + break + } + case 'FileRenamed': { + const payload = msg.payload as { from: string; to: string } + options.onFileRenamed?.(payload.from, payload.to) + break + } + case 'FileLocked': { + const payload = msg.payload as { path: string; client_id: string; lock_type: string } + options.onFileLocked?.(payload.path, payload.client_id, payload.lock_type) + break + } + case 'FileUnlocked': { + const payload = msg.payload as { path: string } + options.onFileUnlocked?.(payload.path) + break + } + case 'GitConflict': { + const payload = msg.payload as { files: string[] } + options.onGitConflict?.(payload.files) + break + } + case 'Ping': + // Heartbeat, no action needed + break + } + } + + function send(message: object) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)) + } + } + + function lockFile(path: string, lockType: 'editor' | 'task_view') { + send({ type: 'lock_file', path, lock_type: lockType }) + } + + function unlockFile(path: string) { + send({ type: 'unlock_file', path }) + } + + function disconnect() { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + reconnectTimeout = null + } + if (ws) { + ws.close() + ws = null + } + } + + onMounted(() => { + connect() + }) + + onUnmounted(() => { + disconnect() + }) + + return { + connected, + clientId, + send, + lockFile, + unlockFile, + disconnect + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..4a4f2bf --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import './style.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..a0c8925 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,53 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'home', + component: () => import('../views/DashboardView.vue') + }, + { + path: '/projects', + name: 'projects', + component: () => import('../views/ProjectsView.vue') + }, + { + path: '/projects/:id', + name: 'project', + component: () => import('../views/ProjectView.vue'), + props: true + }, + { + path: '/projects/:id/notes/:noteId?', + name: 'project-notes', + component: () => import('../views/ProjectNotesView.vue'), + props: true + }, + { + path: '/projects/:id/tasks/:taskId?', + name: 'project-tasks', + component: () => import('../views/TasksView.vue'), + props: true + }, + { + path: '/calendar', + name: 'calendar', + component: () => import('../views/CalendarView.vue') + }, + { + path: '/daily', + name: 'daily', + component: () => import('../views/DailyView.vue') + }, + { + path: '/daily/:date', + name: 'daily-note', + component: () => import('../views/DailyView.vue'), + props: true + } + ] +}) + +export default router diff --git a/frontend/src/stores/git.ts b/frontend/src/stores/git.ts new file mode 100644 index 0000000..dd0cc29 --- /dev/null +++ b/frontend/src/stores/git.ts @@ -0,0 +1,218 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { GitStatus, CommitInfo, CommitDetail, DiffInfo, RemoteInfo } from '../types' +import { gitApi } from '../api/client' + +export const useGitStore = defineStore('git', () => { + // State + const status = ref(null) + const loading = ref(false) + const committing = ref(false) + const pushing = ref(false) + const fetching = ref(false) + const error = ref(null) + const conflicts = ref([]) + + // New state for expanded git features + const history = ref([]) + const historyLoading = ref(false) + const workingDiff = ref(null) + const diffLoading = ref(false) + const selectedCommitDiff = ref(null) + const selectedCommitId = ref(null) + const remote = ref(null) + const panelOpen = ref(false) + + // Getters + const hasChanges = computed(() => status.value?.has_changes ?? false) + const hasConflicts = computed(() => conflicts.value.length > 0) + const branch = computed(() => status.value?.branch ?? 'main') + const isRepo = computed(() => status.value?.is_repo ?? false) + const changedFilesCount = computed(() => status.value?.files.length ?? 0) + const hasRemote = computed(() => remote.value !== null) + const canPush = computed(() => (remote.value?.ahead ?? 0) > 0) + const canPull = computed(() => (remote.value?.behind ?? 0) > 0) + + // Actions + async function loadStatus() { + try { + loading.value = true + error.value = null + status.value = await gitApi.status() + } catch (err) { + error.value = `Failed to load git status: ${err}` + } finally { + loading.value = false + } + } + + async function commit(message?: string): Promise { + try { + committing.value = true + error.value = null + const result = await gitApi.commit(message) + await loadStatus() + // Refresh history and diff after commit + await Promise.all([loadHistory(), loadWorkingDiff(), loadRemote()]) + return result + } catch (err) { + error.value = `Commit failed: ${err}` + return null + } finally { + committing.value = false + } + } + + async function push() { + try { + pushing.value = true + error.value = null + const result = await gitApi.push() + if (!result.success) { + throw new Error(result.message) + } + await Promise.all([loadStatus(), loadRemote()]) + } catch (err) { + error.value = `Push failed: ${err}` + throw err + } finally { + pushing.value = false + } + } + + async function fetchRemote() { + try { + fetching.value = true + error.value = null + const result = await gitApi.fetch() + if (!result.success) { + throw new Error(result.message) + } + await loadRemote() + } catch (err) { + error.value = `Fetch failed: ${err}` + throw err + } finally { + fetching.value = false + } + } + + async function checkConflicts() { + try { + error.value = null + conflicts.value = await gitApi.conflicts() + } catch (err) { + // Conflicts endpoint might not exist yet, ignore error + conflicts.value = [] + } + } + + async function loadHistory(limit?: number) { + try { + historyLoading.value = true + history.value = await gitApi.log(limit) + } catch (err) { + console.error('Failed to load git history:', err) + history.value = [] + } finally { + historyLoading.value = false + } + } + + async function loadWorkingDiff() { + try { + diffLoading.value = true + workingDiff.value = await gitApi.diff() + } catch (err) { + console.error('Failed to load working diff:', err) + workingDiff.value = null + } finally { + diffLoading.value = false + } + } + + async function loadCommitDiff(commitId: string) { + try { + diffLoading.value = true + selectedCommitId.value = commitId + selectedCommitDiff.value = await gitApi.commitDiff(commitId) + } catch (err) { + console.error('Failed to load commit diff:', err) + selectedCommitDiff.value = null + } finally { + diffLoading.value = false + } + } + + async function loadRemote() { + try { + remote.value = await gitApi.remote() + } catch (err) { + console.error('Failed to load remote info:', err) + remote.value = null + } + } + + function clearSelectedCommit() { + selectedCommitId.value = null + selectedCommitDiff.value = null + } + + function togglePanel() { + panelOpen.value = !panelOpen.value + if (panelOpen.value) { + // Load all data when opening panel + Promise.all([ + loadStatus(), + loadHistory(), + loadWorkingDiff(), + loadRemote() + ]) + } + } + + function clearError() { + error.value = null + } + + return { + // State + status, + loading, + committing, + pushing, + fetching, + error, + conflicts, + history, + historyLoading, + workingDiff, + diffLoading, + selectedCommitDiff, + selectedCommitId, + remote, + panelOpen, + // Getters + hasChanges, + hasConflicts, + branch, + isRepo, + changedFilesCount, + hasRemote, + canPush, + canPull, + // Actions + loadStatus, + commit, + push, + fetchRemote, + checkConflicts, + loadHistory, + loadWorkingDiff, + loadCommitDiff, + loadRemote, + clearSelectedCommit, + togglePanel, + clearError + } +}) diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts new file mode 100644 index 0000000..6342bcc --- /dev/null +++ b/frontend/src/stores/index.ts @@ -0,0 +1,8 @@ +export { useNotesStore } from './notes' +export { useProjectsStore } from './projects' +export { useTasksStore } from './tasks' +export { useUiStore } from './ui' +export { useWebSocketStore } from './websocket' +export { useGitStore } from './git' +export { useWorkspaceStore } from './workspace' +export { useThemeStore } from './theme' diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts new file mode 100644 index 0000000..c8e8556 --- /dev/null +++ b/frontend/src/stores/notes.ts @@ -0,0 +1,131 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Note, NoteSummary } from '../types' +import { notesApi } from '../api/client' + +export const useNotesStore = defineStore('notes', () => { + // State + const notes = ref([]) + const currentNote = ref(null) + const loading = ref(false) + const loadingNote = ref(false) + const error = ref(null) + const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle') + + // Getters + const sortedNotes = computed(() => + [...notes.value].sort((a, b) => { + const dateA = a.updated ? new Date(a.updated).getTime() : 0 + const dateB = b.updated ? new Date(b.updated).getTime() : 0 + return dateB - dateA + }) + ) + + const getNoteById = computed(() => (id: string) => + notes.value.find(n => n.id === id) + ) + + // Actions + async function loadNotes() { + try { + loading.value = true + error.value = null + notes.value = await notesApi.list() + } catch (err) { + error.value = `Failed to load notes: ${err}` + } finally { + loading.value = false + } + } + + async function loadNote(id: string) { + try { + loadingNote.value = true + error.value = null + currentNote.value = await notesApi.get(id) + saveStatus.value = 'idle' + } catch (err) { + error.value = `Failed to load note: ${err}` + currentNote.value = null + } finally { + loadingNote.value = false + } + } + + async function createNote() { + try { + error.value = null + const newNote = await notesApi.create() + await loadNotes() + return newNote + } catch (err) { + error.value = `Failed to create note: ${err}` + throw err + } + } + + async function saveNote(content: string) { + if (!currentNote.value) return + + try { + saveStatus.value = 'saving' + currentNote.value = await notesApi.update(currentNote.value.id, content) + saveStatus.value = 'saved' + await loadNotes() // Refresh list to update timestamps + setTimeout(() => { + if (saveStatus.value === 'saved') saveStatus.value = 'idle' + }, 2000) + } catch (err) { + saveStatus.value = 'error' + error.value = `Failed to save note: ${err}` + throw err + } + } + + async function deleteNote(id?: string) { + const noteId = id || currentNote.value?.id + if (!noteId) return + + try { + error.value = null + await notesApi.delete(noteId) + if (currentNote.value?.id === noteId) { + currentNote.value = null + } + await loadNotes() + } catch (err) { + error.value = `Failed to archive note: ${err}` + throw err + } + } + + function clearCurrentNote() { + currentNote.value = null + saveStatus.value = 'idle' + } + + function clearError() { + error.value = null + } + + return { + // State + notes, + currentNote, + loading, + loadingNote, + error, + saveStatus, + // Getters + sortedNotes, + getNoteById, + // Actions + loadNotes, + loadNote, + createNote, + saveNote, + deleteNote, + clearCurrentNote, + clearError + } +}) diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts new file mode 100644 index 0000000..369d6c3 --- /dev/null +++ b/frontend/src/stores/projects.ts @@ -0,0 +1,84 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Project } from '../types' +import { projectsApi } from '../api/client' + +export const useProjectsStore = defineStore('projects', () => { + // State + const projects = ref([]) + const currentProject = ref(null) + const loading = ref(false) + const error = ref(null) + + // Getters + const sortedProjects = computed(() => + [...projects.value].sort((a, b) => a.name.localeCompare(b.name)) + ) + + const getProjectById = computed(() => (id: string) => + projects.value.find(p => p.id === id) + ) + + // Actions + async function loadProjects() { + try { + loading.value = true + error.value = null + projects.value = await projectsApi.list() + } catch (err) { + error.value = `Failed to load projects: ${err}` + } finally { + loading.value = false + } + } + + async function loadProject(id: string) { + try { + loading.value = true + error.value = null + currentProject.value = await projectsApi.get(id) + } catch (err) { + error.value = `Failed to load project: ${err}` + currentProject.value = null + } finally { + loading.value = false + } + } + + async function createProject(name: string) { + try { + error.value = null + const newProject = await projectsApi.create(name) + await loadProjects() + return newProject + } catch (err) { + error.value = `Failed to create project: ${err}` + throw err + } + } + + function clearCurrentProject() { + currentProject.value = null + } + + function clearError() { + error.value = null + } + + return { + // State + projects, + currentProject, + loading, + error, + // Getters + sortedProjects, + getProjectById, + // Actions + loadProjects, + loadProject, + createProject, + clearCurrentProject, + clearError + } +}) diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts new file mode 100644 index 0000000..084923e --- /dev/null +++ b/frontend/src/stores/tasks.ts @@ -0,0 +1,235 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Task, TaskWithContent } from '../types' +import { tasksApi } from '../api/client' + +export const useTasksStore = defineStore('tasks', () => { + // State + const tasks = ref([]) + const allTasks = ref([]) + const currentProjectId = ref(null) + const selectedTask = ref(null) + const loading = ref(false) + const error = ref(null) + + // Getters + const activeTasks = computed(() => + tasks.value.filter(t => !t.completed && t.section !== 'Backlog') + ) + + const completedTasks = computed(() => + tasks.value.filter(t => t.completed || t.section === 'Completed') + ) + + const backlogTasks = computed(() => + tasks.value.filter(t => !t.completed && t.section === 'Backlog') + ) + + const pendingTasks = computed(() => + allTasks.value.filter(t => !t.completed) + ) + + const tasksByProject = computed(() => (projectId: string) => + allTasks.value.filter(t => t.project_id === projectId) + ) + + /** All unique tags used across tasks in the current project */ + const projectTags = computed(() => { + const tagSet = new Set() + for (const task of tasks.value) { + if (task.tags) { + for (const tag of task.tags) { + tagSet.add(tag) + } + } + } + return [...tagSet].sort() + }) + + // Actions + async function loadAllTasks() { + try { + loading.value = true + error.value = null + allTasks.value = await tasksApi.listAll() + } catch (err) { + error.value = `Failed to load tasks: ${err}` + } finally { + loading.value = false + } + } + + async function loadProjectTasks(projectId: string) { + try { + loading.value = true + error.value = null + currentProjectId.value = projectId + tasks.value = await tasksApi.list(projectId) + } catch (err) { + error.value = `Failed to load project tasks: ${err}` + } finally { + loading.value = false + } + } + + async function loadTask(projectId: string, taskId: string) { + try { + error.value = null + selectedTask.value = await tasksApi.get(projectId, taskId) + } catch (err) { + error.value = `Failed to load task: ${err}` + selectedTask.value = null + } + } + + async function createTask(projectId: string, title: string, section?: string, parentId?: string) { + try { + error.value = null + const task = await tasksApi.create(projectId, title, section, parentId) + + // Refresh tasks list + if (currentProjectId.value === projectId) { + await loadProjectTasks(projectId) + } + + return task + } catch (err) { + error.value = `Failed to create task: ${err}` + throw err + } + } + + async function updateTaskContent(projectId: string, taskId: string, content: string) { + try { + error.value = null + const task = await tasksApi.updateContent(projectId, taskId, content) + selectedTask.value = task + + // Refresh tasks list to update timestamps + if (currentProjectId.value === projectId) { + await loadProjectTasks(projectId) + } + + return task + } catch (err) { + error.value = `Failed to update task: ${err}` + throw err + } + } + + async function toggleTask(projectId: string, taskId: string) { + try { + error.value = null + await tasksApi.toggle(projectId, taskId) + + // Refresh tasks + if (currentProjectId.value === projectId) { + await loadProjectTasks(projectId) + } + + // Update selected task if it's the one being toggled + if (selectedTask.value?.id === taskId) { + await loadTask(projectId, taskId) + } + } catch (err) { + error.value = `Failed to toggle task: ${err}` + throw err + } + } + + async function updateTaskMeta( + projectId: string, + taskId: string, + meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number } + ) { + try { + error.value = null + await tasksApi.updateMeta(projectId, taskId, meta) + + // Refresh tasks + if (currentProjectId.value === projectId) { + await loadProjectTasks(projectId) + } + + // Update selected task if it's the one being updated + if (selectedTask.value?.id === taskId) { + await loadTask(projectId, taskId) + } + } catch (err) { + error.value = `Failed to update task: ${err}` + throw err + } + } + + async function deleteTask(projectId: string, taskId: string) { + try { + error.value = null + await tasksApi.delete(projectId, taskId) + + // Clear selected task if it was deleted + if (selectedTask.value?.id === taskId) { + selectedTask.value = null + } + + // Refresh tasks + if (currentProjectId.value === projectId) { + await loadProjectTasks(projectId) + } + } catch (err) { + error.value = `Failed to delete task: ${err}` + throw err + } + } + + function selectTask(task: Task | null) { + if (task && currentProjectId.value) { + loadTask(currentProjectId.value, task.id) + } else { + selectedTask.value = null + } + } + + function clearSelectedTask() { + selectedTask.value = null + } + + function clearProjectTasks() { + tasks.value = [] + currentProjectId.value = null + selectedTask.value = null + } + + function clearError() { + error.value = null + } + + return { + // State + tasks, + allTasks, + currentProjectId, + selectedTask, + loading, + error, + // Getters + activeTasks, + completedTasks, + backlogTasks, + pendingTasks, + tasksByProject, + projectTags, + // Actions + loadAllTasks, + loadProjectTasks, + loadTask, + createTask, + updateTaskContent, + toggleTask, + updateTaskMeta, + deleteTask, + selectTask, + clearSelectedTask, + clearProjectTasks, + clearError + } +}) diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts new file mode 100644 index 0000000..8611a14 --- /dev/null +++ b/frontend/src/stores/theme.ts @@ -0,0 +1,76 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export type ThemeMode = 'dark' | 'light' | 'system' + +const STORAGE_KEY = 'ironpad-theme' + +export const useThemeStore = defineStore('theme', () => { + // Default to dark mode + const mode = ref('dark') + + // Load saved preference + function loadSavedTheme() { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved && ['dark', 'light', 'system'].includes(saved)) { + mode.value = saved as ThemeMode + } + } + + // Get the effective theme (resolves 'system' to actual theme) + function getEffectiveTheme(): 'dark' | 'light' { + if (mode.value === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + return mode.value + } + + // Apply theme to document + function applyTheme() { + const effectiveTheme = getEffectiveTheme() + document.documentElement.setAttribute('data-theme', effectiveTheme) + + // Also set class for easier CSS targeting + document.documentElement.classList.remove('theme-dark', 'theme-light') + document.documentElement.classList.add(`theme-${effectiveTheme}`) + } + + // Set theme mode + function setTheme(newMode: ThemeMode) { + mode.value = newMode + localStorage.setItem(STORAGE_KEY, newMode) + applyTheme() + } + + // Toggle between dark and light + function toggleTheme() { + const current = getEffectiveTheme() + setTheme(current === 'dark' ? 'light' : 'dark') + } + + // Initialize + function init() { + loadSavedTheme() + + // Ensure data-theme attribute is set (even if same as default) + applyTheme() + + // Listen for system theme changes when in system mode + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (mode.value === 'system') { + applyTheme() + } + }) + + // Theme is now initialized + } + + return { + mode, + getEffectiveTheme, + setTheme, + toggleTheme, + applyTheme, + init + } +}) diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts new file mode 100644 index 0000000..383eae9 --- /dev/null +++ b/frontend/src/stores/ui.ts @@ -0,0 +1,125 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { SearchResult } from '../types' +import { searchApi } from '../api/client' + +export const useUiStore = defineStore('ui', () => { + // State + const showSearch = ref(false) + const showTasks = ref(false) + const showPreview = ref(false) + const searchQuery = ref('') + const searchResults = ref([]) + const isSearching = ref(false) + const globalError = ref(null) + const sidebarSection = ref<'notes' | 'projects' | 'daily'>('notes') + + // Getters + const hasSearchResults = computed(() => searchResults.value.length > 0) + + // Actions + function openSearch() { + showSearch.value = true + showTasks.value = false + } + + function closeSearch() { + showSearch.value = false + searchQuery.value = '' + searchResults.value = [] + } + + function toggleSearch() { + if (showSearch.value) { + closeSearch() + } else { + openSearch() + } + } + + function openTasks() { + showTasks.value = true + showSearch.value = false + } + + function closeTasks() { + showTasks.value = false + } + + function toggleTasks() { + if (showTasks.value) { + closeTasks() + } else { + openTasks() + } + } + + function togglePreview() { + showPreview.value = !showPreview.value + // Persist preference + localStorage.setItem('ironpad-show-preview', String(showPreview.value)) + } + + function loadPreviewPreference() { + const saved = localStorage.getItem('ironpad-show-preview') + if (saved !== null) { + showPreview.value = saved === 'true' + } + } + + async function search(query: string) { + if (query.length < 2) { + searchResults.value = [] + return + } + + try { + isSearching.value = true + searchQuery.value = query + searchResults.value = await searchApi.search(query) + } catch (err) { + globalError.value = `Search failed: ${err}` + } finally { + isSearching.value = false + } + } + + function setSidebarSection(section: 'notes' | 'projects' | 'daily') { + sidebarSection.value = section + } + + function setGlobalError(message: string | null) { + globalError.value = message + } + + function clearGlobalError() { + globalError.value = null + } + + return { + // State + showSearch, + showTasks, + showPreview, + searchQuery, + searchResults, + isSearching, + globalError, + sidebarSection, + // Getters + hasSearchResults, + // Actions + openSearch, + closeSearch, + toggleSearch, + openTasks, + closeTasks, + toggleTasks, + togglePreview, + loadPreviewPreference, + search, + setSidebarSection, + setGlobalError, + clearGlobalError + } +}) diff --git a/frontend/src/stores/websocket.ts b/frontend/src/stores/websocket.ts new file mode 100644 index 0000000..1956656 --- /dev/null +++ b/frontend/src/stores/websocket.ts @@ -0,0 +1,67 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { FileLock } from '../types' + +export const useWebSocketStore = defineStore('websocket', () => { + // State + const connected = ref(false) + const clientId = ref(null) + const fileLocks = ref>(new Map()) + const gitConflicts = ref([]) + + // Actions + function setConnected(value: boolean) { + connected.value = value + } + + function setClientId(id: string | null) { + clientId.value = id + } + + function addFileLock(lock: FileLock) { + fileLocks.value.set(lock.path, lock) + } + + function removeFileLock(path: string) { + fileLocks.value.delete(path) + } + + function isFileLocked(path: string): FileLock | undefined { + return fileLocks.value.get(path) + } + + function isFileLockedByOther(path: string): boolean { + const lock = fileLocks.value.get(path) + return lock !== undefined && lock.client_id !== clientId.value + } + + function setGitConflicts(files: string[]) { + gitConflicts.value = files + } + + function clearGitConflicts() { + gitConflicts.value = [] + } + + function clearAllLocks() { + fileLocks.value.clear() + } + + return { + // State + connected, + clientId, + fileLocks, + gitConflicts, + // Actions + setConnected, + setClientId, + addFileLock, + removeFileLock, + isFileLocked, + isFileLockedByOther, + setGitConflicts, + clearGitConflicts, + clearAllLocks + } +}) diff --git a/frontend/src/stores/workspace.ts b/frontend/src/stores/workspace.ts new file mode 100644 index 0000000..ebabed9 --- /dev/null +++ b/frontend/src/stores/workspace.ts @@ -0,0 +1,76 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { Project } from '../types' +import { projectsApi } from '../api/client' + +const STORAGE_KEY = 'ironpad-active-project' + +export const useWorkspaceStore = defineStore('workspace', () => { + // State + const activeProjectId = ref(null) + const activeProject = ref(null) + const loading = ref(false) + const error = ref(null) + + // Getters + const hasActiveProject = computed(() => activeProjectId.value !== null) + + // Actions + async function setActiveProject(projectId: string | null) { + if (projectId === activeProjectId.value) return + + activeProjectId.value = projectId + + if (projectId) { + // Persist to localStorage + localStorage.setItem(STORAGE_KEY, projectId) + + // Load project details + try { + loading.value = true + error.value = null + activeProject.value = await projectsApi.get(projectId) + } catch (err) { + error.value = `Failed to load project: ${err}` + activeProject.value = null + } finally { + loading.value = false + } + } else { + localStorage.removeItem(STORAGE_KEY) + activeProject.value = null + } + } + + async function loadSavedProject() { + const savedId = localStorage.getItem(STORAGE_KEY) + if (savedId) { + await setActiveProject(savedId) + } + } + + function clearActiveProject() { + activeProjectId.value = null + activeProject.value = null + localStorage.removeItem(STORAGE_KEY) + } + + function clearError() { + error.value = null + } + + return { + // State + activeProjectId, + activeProject, + loading, + error, + // Getters + hasActiveProject, + // Actions + setActiveProject, + loadSavedProject, + clearActiveProject, + clearError + } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..2c36693 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,242 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* + * Theme variables are defined in App.vue + * This file only contains component styles + */ + +#app { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* Sidebar */ +.sidebar { + width: var(--sidebar-width); + min-width: var(--sidebar-width); + background: var(--color-bg-secondary); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-header { + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.sidebar-header h1 { + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.sidebar-content { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +/* Note list */ +.note-list { + list-style: none; +} + +.note-item { + padding: 10px 16px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.15s, border-color 0.15s; +} + +.note-item:hover { + background: var(--color-border); +} + +.note-item.active { + background: var(--color-border); + border-left-color: var(--color-primary); +} + +.note-item-title { + font-weight: 500; + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-item-meta { + font-size: 12px; + color: var(--color-text-secondary); +} + +/* Main content */ +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.main-header { + height: var(--header-height); + padding: 0 16px; + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.main-header h2 { + font-size: 14px; + font-weight: 500; + margin: 0; +} + +.main-content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Editor */ +.editor-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor { + flex: 1; + width: 100%; + padding: 16px 24px; + border: none; + resize: none; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + font-size: 14px; + line-height: 1.6; + background: var(--color-bg); + color: var(--color-text); + outline: none; +} + +.editor:focus { + outline: none; +} + +/* Empty state */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: var(--color-text-secondary); + padding: 32px; +} + +.empty-state h2 { + margin-bottom: 8px; + font-weight: 500; +} + +/* Buttons */ +button { + padding: 6px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-text); + font-size: 13px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +button:hover { + background: var(--color-bg-secondary); + border-color: var(--color-text-secondary); +} + +button.primary { + background: var(--color-primary); + border-color: var(--color-primary); + color: white; +} + +button.primary:hover { + opacity: 0.9; +} + +button.danger { + color: var(--color-danger); +} + +button.danger:hover { + background: var(--color-danger); + border-color: var(--color-danger); + color: white; +} + +/* Status indicators */ +.status { + font-size: 12px; + color: var(--color-text-secondary); + display: flex; + align-items: center; + gap: 6px; +} + +.status.saving { + color: var(--color-primary); +} + +.status.saved { + color: var(--color-success); +} + +.status.error { + color: var(--color-danger); +} + +/* Loading */ +.loading { + padding: 16px; + color: var(--color-text-secondary); + text-align: center; +} + +/* Error */ +.error-message { + padding: 12px 16px; + background: var(--color-danger); + color: white; + font-size: 13px; +} + +/* Button group */ +.button-group { + display: flex; + gap: 8px; +} + +/* Type badge */ +.type-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + background: var(--color-border); + color: var(--color-text-secondary); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..658f30c --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,175 @@ +// Types for Ironpad + +export interface NoteSummary { + id: string + title: string + path: string + note_type: string + updated?: string +} + +export interface Note { + id: string + path: string + note_type: string + frontmatter: Record + content: string +} + +export interface Project { + id: string + name: string + path: string + created: string +} + +export interface ProjectWithContent extends Project { + content: string +} + +export interface ProjectNote { + id: string + title: string + path: string + project_id: string + created: string + updated: string +} + +export interface ProjectNoteWithContent extends ProjectNote { + content: string +} + +export interface Task { + id: string + title: string + completed: boolean + section: string + priority?: string + due_date?: string + is_active: boolean + tags: string[] + parent_id?: string + recurrence?: string + recurrence_interval?: number + project_id: string + path: string + created: string + updated: string +} + +export interface TaskWithContent extends Task { + content: string +} + +export interface SearchResult { + path: string + title: string + matches: { line_number: number; line_content: string }[] +} + +export interface GitStatus { + is_repo: boolean + branch?: string + has_changes: boolean + files: { path: string; status: string }[] + last_commit?: { id: string; message: string; timestamp: string } + conflicts?: string[] +} + +export interface CommitInfo { + id: string + message: string + timestamp: string +} + +export interface CommitDetail { + id: string + short_id: string + message: string + author: string + timestamp: string + files_changed: number +} + +export interface DiffLine { + origin: string + content: string +} + +export interface DiffHunk { + header: string + lines: DiffLine[] +} + +export interface FileDiff { + path: string + status: string + additions: number + deletions: number + hunks: DiffHunk[] +} + +export interface DiffStats { + files_changed: number + insertions: number + deletions: number +} + +export interface DiffInfo { + files: FileDiff[] + stats: DiffStats +} + +export interface RemoteInfo { + name: string + url: string + has_upstream: boolean + ahead: number + behind: number +} + +export interface DailyNote { + id: string + date: string + path: string + content: string + frontmatter: Record +} + +export interface FileLock { + path: string + client_id: string + lock_type: 'editor' | 'task_view' +} + +// WebSocket message types +export type WsMessageType = + | 'Connected' + | 'FileCreated' + | 'FileModified' + | 'FileDeleted' + | 'FileRenamed' + | 'FileLocked' + | 'FileUnlocked' + | 'GitConflict' + | 'Ping' + +export interface WsMessage { + type: WsMessageType + payload?: unknown +} + +export interface WsConnectedPayload { + client_id: string +} + +export interface WsFilePayload { + path: string +} + +export interface WsFileLockPayload { + path: string + client_id: string + lock_type: 'editor' | 'task_view' +} diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue new file mode 100644 index 0000000..8c5be0a --- /dev/null +++ b/frontend/src/views/CalendarView.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend/src/views/DailyView.vue b/frontend/src/views/DailyView.vue new file mode 100644 index 0000000..2331044 --- /dev/null +++ b/frontend/src/views/DailyView.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue new file mode 100644 index 0000000..478b380 --- /dev/null +++ b/frontend/src/views/DashboardView.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/frontend/src/views/NotesView.vue b/frontend/src/views/NotesView.vue new file mode 100644 index 0000000..ac568ad --- /dev/null +++ b/frontend/src/views/NotesView.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/frontend/src/views/ProjectNotesView.vue b/frontend/src/views/ProjectNotesView.vue new file mode 100644 index 0000000..f60e9ce --- /dev/null +++ b/frontend/src/views/ProjectNotesView.vue @@ -0,0 +1,529 @@ + + + + + diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue new file mode 100644 index 0000000..0e370b2 --- /dev/null +++ b/frontend/src/views/ProjectView.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/src/views/ProjectsView.vue b/frontend/src/views/ProjectsView.vue new file mode 100644 index 0000000..8ad063d --- /dev/null +++ b/frontend/src/views/ProjectsView.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/frontend/src/views/TasksView.vue b/frontend/src/views/TasksView.vue new file mode 100644 index 0000000..e3f54f0 --- /dev/null +++ b/frontend/src/views/TasksView.vue @@ -0,0 +1,1624 @@ + + + + + diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..8d16e42 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3acc2af --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:3000', + ws: true, + }, + }, + }, +})