diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml deleted file mode 100644 index ec62ff4..0000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -1,67 +0,0 @@ -# Branch push (dev): builds and pushes :dev + : -# Branch push (main): builds and pushes :latest + : -# Tag push (v1.2.0): builds and pushes :latest + : + :v1.2.0 -# -# To cut a versioned release: -# git tag v1.2.0 && git push origin v1.2.0 -# -# Required secrets (repo → Settings → Secrets → Actions): -# REGISTRY_USER — your Forgejo username -# REGISTRY_TOKEN — Forgejo PAT with write:packages scope -name: Build & push image - -on: - push: - branches: [main, dev] - tags: ["v*"] - paths: - - "src/**" - - "frontend/**" - - "Dockerfile" - - "pyproject.toml" - - "alembic/**" - - "alembic.ini" - - ".forgejo/workflows/build.yml" - -env: - REGISTRY: git.fabledsword.com - IMAGE: git.fabledsword.com/bvandeusen/fabledassistant - -jobs: - build: - name: Build & push - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Generate image tags - id: tags - run: | - TAGS="${{ env.IMAGE }}:${{ github.sha }}" - if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - TAGS="$TAGS,${{ env.IMAGE }}:latest" - elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then - TAGS="$TAGS,${{ env.IMAGE }}:dev" - elif [[ "${{ github.ref }}" == refs/tags/* ]]; then - TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" - fi - echo "value=$TAGS" >> $GITHUB_OUTPUT - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Forgejo registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: ${{ steps.tags.outputs.value }} - cache-from: type=registry,ref=${{ env.IMAGE }}:cache - cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 9a30c52..4fec19d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,14 +1,28 @@ -# Runs on every push and pull request. -# Fast checks only — no Docker build. Second runs take ~30s due to caching. -name: CI +# CI runs first; build only proceeds if all checks pass. +# +# Push to dev: typecheck + lint + test → build :dev + : +# Push to main: typecheck + lint + test only (no build — wait for release tag) +# Tag v*: typecheck + lint + test → build :latest + : + : +# Pull request: typecheck + lint + test only (no build) +# +# Required secrets (repo → Settings → Secrets → Actions): +# REGISTRY_USER — your Forgejo username +# REGISTRY_TOKEN — Forgejo PAT with write:packages scope +name: CI & Build on: push: + branches: [main, dev] + tags: ["v*"] paths: - "src/**" - "frontend/**" - "tests/**" - "pyproject.toml" + - "alembic/**" + - "alembic.ini" + - "Dockerfile" + - "assets/**" - ".forgejo/workflows/ci.yml" pull_request: paths: @@ -18,16 +32,20 @@ on: - "pyproject.toml" - ".forgejo/workflows/ci.yml" +env: + REGISTRY: git.fabledsword.com + IMAGE: git.fabledsword.com/bvandeusen/fabledassistant + jobs: typecheck: name: TypeScript typecheck - runs-on: ubuntu-latest + runs-on: py3.12-node22 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: frontend/package-lock.json @@ -41,32 +59,85 @@ jobs: lint: name: Python lint - runs-on: ubuntu-latest - container: - image: python:3.12-slim + runs-on: py3.12-node22 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install ruff - run: pip install ruff + run: pip install --break-system-packages ruff - name: Lint run: ruff check src/ test: name: Python tests - runs-on: ubuntu-latest - container: - image: python:3.12-slim + runs-on: py3.12-node22 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - # git is required by setuptools-scm during package build - - name: Install git - run: apt-get update -qq && apt-get install -y -qq git + # Python 3.12 is pre-installed in the runner-base image (py3.12-node22). + - name: Cache pip wheels + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: pip-${{ hashFiles('pyproject.toml') }} + restore-keys: pip- + + - name: Create virtual environment + run: python3.12 -m venv /opt/venv - name: Install package with dev deps - run: pip install -e ".[dev]" + run: | + /opt/venv/bin/pip install --upgrade pip setuptools wheel + /opt/venv/bin/pip install --no-build-isolation http-ece + /opt/venv/bin/pip install -e ".[dev]" - name: Run tests - run: pytest tests/ -v + run: /opt/venv/bin/python -m pytest tests/ -v + + build: + name: Build & push image + needs: [typecheck, lint, test] + # Build on dev branch pushes and version tag pushes only. + # main branch pushes run CI for safety but do not build — + # the release tag (v*) is the sole trigger for a production image. + if: | + github.event_name == 'push' && + (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) + runs-on: py3.12-node22 + steps: + - uses: actions/checkout@v6 + + - name: Generate image tags and version + id: tags + run: | + TAGS="${{ env.IMAGE }}:${{ github.sha }}" + BUILD_VERSION="dev" + if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + TAGS="$TAGS,${{ env.IMAGE }}:dev" + elif [[ "${{ github.ref }}" == refs/tags/* ]]; then + TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" + BUILD_VERSION="${{ github.ref_name }}" + fi + echo "value=$TAGS" >> $GITHUB_OUTPUT + echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Forgejo registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + push: true + tags: ${{ steps.tags.outputs.value }} + build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }} + cache-from: type=registry,ref=${{ env.IMAGE }}:cache + cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max diff --git a/.gitignore b/.gitignore index 1deb830..507f415 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,15 @@ frontend/dist/ # IDE .vscode/ +.vscodium/ .idea/ *.swp *.swo +settings.local.json + +# Claude Code +.claude/ +docs/superpowers/ # Environment .env @@ -27,3 +33,4 @@ docker-compose.override.yml # Misc *.log .DS_Store +.superpowers/ diff --git a/Dockerfile b/Dockerfile index 3742242..699e156 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build Vue frontend -FROM node:20-alpine AS build-frontend +FROM node:22-alpine AS build-frontend WORKDIR /build COPY frontend/package.json frontend/package-lock.json* ./ RUN npm install -g npm@latest --quiet && npm install @@ -21,5 +21,10 @@ COPY alembic/ alembic/ # Ensure Python finds the source tree (where static files live) before site-packages ENV PYTHONPATH=/app/src +# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N +# Falls back to "dev" for local / untagged builds +ARG BUILD_VERSION=dev +ENV APP_VERSION=$BUILD_VERSION + EXPOSE 5000 CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] diff --git a/README.md b/README.md index 5126ae6..54a2346 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,61 @@ # Fabled Assistant -A self-hosted note-taking and task-tracking application with integrated LLM capabilities. Write, organize, and enhance your notes with the help of a local AI assistant — all running on your own hardware. +A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware. ## What It Does -**Notes with inline formatting** — Write in Markdown with a live-preview editor. Headings, bold, italic, lists, and code blocks render inline as you type, similar to Obsidian or Typora. +**Notes with inline formatting** — Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks without leaving the keyboard. -**Task tracking** — Any note can become a task with a status (todo, in progress, done), priority level, and due date. Convert freely between notes and tasks without losing content. +**Task tracking** — Notes convert freely to tasks (and back). Tasks carry status (`todo` → `in_progress` → `done`), priority, due date, sub-tasks, milestone assignment, and work logs with time tracking. -**Wikilinks and backlinks** — Link notes together with `[[Title]]` syntax. Click a wikilink to navigate to (or auto-create) the referenced note. Each note shows what links to it. +**Projects and milestones** — Group related notes and tasks into projects. Milestones give projects a timeline and show completion progress. A kanban-style project view groups tasks by milestone. -**Tag organization** — Add `#tags` anywhere in your text. Tags are extracted automatically and support hierarchical filtering (e.g., `#project/webapp` matches both `project` and `project/webapp`). Tag autocomplete suggests existing tags as you type. +**Project Workspace** — `/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically. -**AI writing assistant** — Select a section of your note and give the assistant an instruction ("summarize this", "make it more concise", "add examples"). The assistant streams a suggestion that you can accept or reject. Powered by Ollama running locally. +**Wikilinks and backlinks** — Link notes with `[[Title]]` syntax. Click a wikilink to navigate to (or create) the referenced note. Each note shows what links to it. The editor suggests existing note titles as candidate links. -**AI chat** — Have conversations with your AI assistant. The chat is context-aware: it can automatically find and reference your notes to give more relevant answers. Attach specific notes for focused discussions. Save useful responses as new notes. +**Tag organisation** — Tags are first-class columns (stored as a PostgreSQL array, not extracted from body text). Tag autocomplete, tag-based filtering, and a force-directed graph view show how notes cluster. -**Multi-user support** — Multiple users can share the same instance with isolated data. The first registered user becomes the admin. +**Knowledge graph** — `/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; project hub nodes (invisible) attract project members. Click any node to open a slide-in peek panel. -**Dark and light themes** — Defaults to dark mode with a one-click toggle. All views respect your preference. +**AI chat** — Full conversation history with SSE streaming. The assistant automatically retrieves semantically relevant notes (RAG) and injects them as context. Attach specific notes by paperclip for focused discussions. Useful replies can be saved directly as notes. + +**AI writing assistant** — Select a passage in the editor, give an instruction ("make this more concise", "add examples"), and stream a diff-style suggestion you can accept or reject. + +**Web research** — The assistant can search the web (SearXNG), fetch pages, and synthesise findings. Research results are saved as notes. A lightweight `search_web` tool answers quick questions inline. + +**Collaboration and sharing** — Share any project or note with other users or groups. Three permission levels: `viewer`, `editor`, `admin`. A "Shared with me" page lists all incoming shares. In-app notifications (with push) alert recipients when items are shared or when they are added to a group. + +**Groups** — Admins can create platform-wide groups, assign users roles (`member` / `owner`), and share resources with the group in one action. + +**In-app notifications** — A bell icon in the nav shows unread notification count with a 60-second polling interval. Clicking a notification navigates to the relevant resource and marks it read. + +**CalDAV calendar** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) and have the assistant create, list, search, update, and delete calendar events via natural language. + +**Push notifications** — Web Push (VAPID) notifies you when AI generation completes, even in another tab. Configurable per-user from the Notifications settings tab. + +**PWA** — Installable as a desktop or mobile app. Service worker caches the shell; push is handled by `public/sw.js`. + +**Data export and backup** — Export your data as a Markdown ZIP (with YAML frontmatter) or a JSON array from the Data settings tab. Admins can export/restore full application backups (version 2 includes projects, milestones, task logs, AI drafts, note versions, push subscriptions) with proper ID remapping on restore. + +**OAuth / OIDC login** — Supports PKCE-based OIDC in addition to local username/password auth. `LOCAL_AUTH_ENABLED` can disable local login entirely for SSO-only deployments. + +**Multi-user with isolation** — All data is scoped to the owning user. Access to shared resources is resolved through `services/access.py` using the permission rank system. The first registered user becomes admin. + +**Daily Briefing** — A scheduled, dialogue-based morning briefing accessible at `/briefing`. The assistant compiles your tasks, calendar events, projects, weather forecast (Open-Meteo), and RSS feed digest at 4am, then checks in at 8am, 12pm, and 4pm. You can reply interactively — the briefing is a real conversation, not a widget. The assistant learns your preferences over time via a profile note it maintains. Configure locations, work schedule, RSS feeds, and active slots from Settings → Briefing. + +**Dark and light themes** — Defaults to dark (slate-indigo palette). One-click toggle in the header. + +**Keyboard shortcuts** — `g`+`h/n/t/p/c/g` navigate sections; `n` new note; `t` new task; `?` shows the shortcut panel. Full list available in-app. + +--- ## Getting Started ### Prerequisites - [Docker](https://docs.docker.com/get-docker/) and Docker Compose -- A machine with enough resources to run an LLM (8GB+ RAM recommended for smaller models) +- A machine with enough RAM to run an LLM (8 GB+ recommended for smaller models like `llama3.2`) ### Quick Start @@ -42,54 +72,69 @@ A self-hosted note-taking and task-tracking application with integrated LLM capa 3. Open `http://localhost:5000` in your browser. -4. Register the first user account (this account becomes the admin). +4. Register the first user account — this account becomes the admin. -5. Go to **Settings** to download an LLM model. Smaller models like `llama3.2` (2GB) work well for getting started. +5. Go to **Settings → General** to pull an LLM model (`llama3.2` at 2 GB is a good starting point). ### Day-to-Day Usage -- **Create a note** from the Notes page. Use Markdown — formatting renders live in the editor. -- **Link notes** by typing `[[` to get a wikilink autocomplete dropdown. -- **Tag your notes** by typing `#` followed by a tag name. Autocomplete suggests existing tags. -- **Use the AI assistant** in the bottom panel of the editor. Select a section, write an instruction, and click Generate. -- **Chat with the AI** from the Chat page. Attach notes for context or let the assistant find relevant notes automatically. -- **Convert notes to tasks** from the note viewer to add status, priority, and due dates. -- **Save chat responses** as notes to preserve useful AI-generated content. -- **Backup your data** from Settings (admin users can export and restore full backups). +- **Create a note** from the Notes page. Use Markdown — formatting renders live. +- **Link notes** by typing `[[` for a wikilink autocomplete dropdown. +- **Tag your notes** — add tags in the sidebar tag input; autocomplete suggests existing tags. +- **Use the AI writing assistant** — select text in the editor, write an instruction, stream a suggestion. +- **Chat with the AI** — the assistant finds relevant notes automatically. Attach specific notes for focused context. +- **Convert notes ↔ tasks** from the viewer toolbar. +- **Open a project workspace** from the project page — chat, tasks, and note editor in one view. +- **Share a project or note** — click Share in the project/note/task viewer toolbar, search for users or pick a group. +- **Manage groups** (admins) — Settings → Groups tab. +- **Backup your data** — Settings → Data tab for personal export; admin section for full application backup. + +--- ## Configuration -Configuration is done via environment variables. See `docker-compose.yml` for defaults. +Configuration is via environment variables. See `docker-compose.yml` for defaults. | Variable | Default | Description | |----------|---------|-------------| | `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL connection string | | `SECRET_KEY` | (required) | Session signing key | | `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint | -| `DEFAULT_MODEL` | `llama3.1` | Default LLM model to auto-pull on startup | -| `SECURE_COOKIES` | `false` | Set to `true` when behind TLS to add `Secure` flag to session cookies | -| `LOG_LEVEL` | `INFO` | Logging level | +| `DEFAULT_MODEL` | `llama3.1` | LLM model to warm on startup | +| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG | +| `SECURE_COOKIES` | `false` | Set `true` behind TLS | +| `LOG_LEVEL` | `INFO` | Logging verbosity | +| `OIDC_ISSUER` | — | OIDC issuer URL for SSO login | +| `OIDC_CLIENT_ID` | — | OIDC client ID | +| `OIDC_CLIENT_SECRET` | — | OIDC client secret | +| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local login | +| `SEARXNG_URL` | — | SearXNG base URL for web search | +| `BASE_URL` | — | Public URL (used in email links and OIDC redirect) | -For production deployments, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits. +For production, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits. + +--- ## Production Deployment ### Reverse Proxy (Required) -Fabled Assistant does **not** handle SSL/TLS. You must run it behind a reverse proxy for production use: +Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy: - **Nginx**, **Traefik**, or **Caddy** in front of the app container -- Terminate TLS at the proxy and forward traffic to port 5000 +- Terminate TLS at the proxy; forward to port 5000 - **Do not expose port 5000 directly to the internet** -- **Rate-limit auth endpoints** at the proxy layer — recommended: limit `/api/auth/login` and `/api/auth/register` to ~5 requests/minute per IP to prevent brute-force attacks -- **Set Content Security Policy headers** at the proxy — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'` +- **Rate-limit auth endpoints** — recommended: ≤5 req/min per IP on `/api/auth/login` and `/api/auth/register` +- **Set CSP headers** — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'` ### Security Checklist -- **Set a strong `SECRET_KEY`** — used to sign session cookies. Generate one with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`. -- **Registration auto-closes** — After the first user registers (who becomes admin), registration is closed by default. The admin can re-enable it from the user management page (`/admin/users`). -- **Use Docker secrets in production** — `docker-compose.prod.yml` reads `SECRET_KEY_FILE` and `DATABASE_URL_FILE` instead of plain environment variables. -- **Keep Ollama on an internal network** — The default compose files keep Ollama on a Docker-internal network, not exposed to the host. +- **Strong `SECRET_KEY`** — generate with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE` +- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links. +- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions. +- **Keep Ollama on an internal network** — both compose files keep it off the host network. + +--- ## Technical Overview @@ -98,61 +143,69 @@ Fabled Assistant does **not** handle SSL/TLS. You must run it behind a reverse p | Layer | Technology | |-------|------------| | Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router | -| Editor | Tiptap (ProseMirror) with custom extensions | -| Backend | Python 3.12, Quart (async Flask-like framework) | -| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic migrations | -| LLM | Ollama (or any OpenAI-compatible API) | -| Deployment | Docker Compose (app + PostgreSQL + Ollama) | +| Editor | Tiptap (ProseMirror) with custom slash-command extension | +| Backend | Python 3.12, Quart (async ASGI) | +| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic | +| LLM | Ollama (local) — any OpenAI-compatible API | +| Search | SearXNG (optional, self-hosted) | +| Push | Web Push / VAPID (pywebpush 2.x) | +| Deployment | Docker Compose | ### Architecture -The application runs as a single container serving both the Vue.js SPA and the REST API. The frontend is built by Vite during the Docker image build and served as static files by Quart. API routes live under `/api/`. +The app runs as a single container serving the Vue SPA and REST API (`/api/`). The frontend is built by Vite during the Docker image build and served as static files by Quart. -LLM interactions use Server-Sent Events (SSE) for streaming responses. Chat generation runs in background `asyncio` tasks with an in-memory event buffer that supports client reconnection without data loss. +LLM interactions stream via Server-Sent Events (SSE). Chat generation runs in background `asyncio` tasks with an in-memory event buffer supporting client reconnection without data loss. An abort mechanism lets users cancel in-flight generations. -The editor uses a Markdown-to-HTML-to-Markdown round-trip: content is stored as Markdown, converted to HTML for Tiptap editing, and serialized back to Markdown on every change. ProseMirror decoration plugins provide visual highlighting for tags and wikilinks without custom node types. +Semantic search (RAG) uses `nomic-embed-text` via Ollama to generate embeddings stored in PostgreSQL. Notes above 0.60 cosine similarity are auto-injected into the system prompt; notes between 0.45–0.60 appear as sidebar suggestions. + +Permission resolution is centralised in `services/access.py`. All resource access goes through `get_project_permission` / `get_note_permission`, which check ownership, direct shares, group membership shares, and note→project inheritance in order, returning the highest applicable permission. ### Database -All data is stored in PostgreSQL. The schema uses a unified model where tasks are notes with additional attributes (`status`, `priority`, `due_date`). Migrations are idempotent raw SQL and run automatically on container startup. +PostgreSQL with SQLAlchemy 2.0 async. Tasks are notes with non-null `status` (unified `Note` model). Tags are stored as a `ARRAY[text]` column. Migrations run automatically on startup via Alembic. ### Project Structure ``` fabledassistant/ -├── docker-compose.yml # Development stack -├── docker-compose.prod.yml # Production stack (Docker Swarm) -├── Dockerfile # Multi-stage build (Node + Python) -├── alembic/ # Database migrations -├── src/fabledassistant/ # Python backend -│ ├── app.py # Quart app factory -│ ├── models/ # SQLAlchemy models -│ ├── routes/ # API route blueprints -│ ├── services/ # Business logic -│ └── static/ # Built frontend (generated) -└── frontend/ # Vue.js frontend +├── docker-compose.yml # Development stack +├── docker-compose.prod.yml # Production stack (Docker Swarm) +├── Dockerfile # Multi-stage build (Node → Python) +├── alembic/ # Database migrations +├── src/fabledassistant/ +│ ├── app.py # Quart app factory + blueprint registration +│ ├── models/ # SQLAlchemy models +│ ├── routes/ # API blueprints +│ ├── services/ # Business logic (access, sharing, groups, …) +│ └── static/ # Built frontend (generated at build time) +└── frontend/ └── src/ - ├── views/ # Page components - ├── components/ # Reusable UI components - ├── extensions/ # Tiptap/ProseMirror plugins - ├── composables/ # Vue composables - ├── stores/ # Pinia state management - └── api/ # API client + ├── views/ # Page-level components + ├── components/ # Reusable UI components + ├── composables/ # Vue composables (autosave, shortcuts, …) + ├── stores/ # Pinia stores (auth, chat, notes, notifications, …) + └── api/ # Typed API client (client.ts) ``` ### Development -All development is done via Docker: +All development is done via Docker. No local dependency installation required. ```bash -# Start the dev stack +# Start the dev stack (hot-reload not included — rebuild on changes) docker compose up --build -# Reset database (destroy volumes and rebuild) +# Reset the database docker compose down -v && docker compose up --build + +# Lint, format, typecheck, test (runs inside Docker via Makefile) +make check ``` -No local dependency installation required. +CI runs on Forgejo Actions with a custom runner image (`py3.12-node22`) that has Python 3.12 and Node 22 pre-installed. Pushes to `dev` build a `:dev` image; merges to `main` build `:latest`. + +--- ## License diff --git a/alembic/versions/0025_add_sharing_and_notifications.py b/alembic/versions/0025_add_sharing_and_notifications.py new file mode 100644 index 0000000..a9412d6 --- /dev/null +++ b/alembic/versions/0025_add_sharing_and_notifications.py @@ -0,0 +1,209 @@ +"""Add groups, group_memberships, project_shares, note_shares, notifications tables. + +Revision ID: 0025 +Revises: 0024 +Create Date: 2026-03-11 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0025" +down_revision = "0024" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "groups", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.Text(), nullable=False, unique=True), + sa.Column("description", sa.Text()), + sa.Column( + "created_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + op.create_table( + "group_memberships", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "group_id", + sa.Integer(), + sa.ForeignKey("groups.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("role", sa.Text(), nullable=False, server_default="member"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("group_id", "user_id", name="uq_gm_group_user"), + ) + op.create_index("idx_gm_user", "group_memberships", ["user_id"]) + + op.create_table( + "project_shares", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "project_id", + sa.Integer(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "shared_with_user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + ), + sa.Column( + "shared_with_group_id", + sa.Integer(), + sa.ForeignKey("groups.id", ondelete="CASCADE"), + ), + sa.Column("permission", sa.Text(), nullable=False), + sa.Column( + "invited_by", + sa.Integer(), + sa.ForeignKey("users.id"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ps_exclusive_target", + ), + ) + op.create_index("idx_ps_project", "project_shares", ["project_id"]) + op.execute( + "CREATE UNIQUE INDEX idx_ps_user ON project_shares(project_id, shared_with_user_id)" + " WHERE shared_with_user_id IS NOT NULL" + ) + op.execute( + "CREATE UNIQUE INDEX idx_ps_group ON project_shares(project_id, shared_with_group_id)" + " WHERE shared_with_group_id IS NOT NULL" + ) + + op.create_table( + "note_shares", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "note_id", + sa.Integer(), + sa.ForeignKey("notes.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "shared_with_user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + ), + sa.Column( + "shared_with_group_id", + sa.Integer(), + sa.ForeignKey("groups.id", ondelete="CASCADE"), + ), + sa.Column("permission", sa.Text(), nullable=False), + sa.Column( + "invited_by", + sa.Integer(), + sa.ForeignKey("users.id"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ns_exclusive_target", + ), + ) + op.execute( + "CREATE UNIQUE INDEX idx_ns_user ON note_shares(note_id, shared_with_user_id)" + " WHERE shared_with_user_id IS NOT NULL" + ) + op.execute( + "CREATE UNIQUE INDEX idx_ns_group ON note_shares(note_id, shared_with_group_id)" + " WHERE shared_with_group_id IS NOT NULL" + ) + + op.create_table( + "notifications", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("type", sa.Text(), nullable=False), + sa.Column( + "payload", + postgresql.JSONB(), + nullable=False, + server_default="{}", + ), + sa.Column("read_at", sa.DateTime(timezone=True)), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.execute( + "CREATE INDEX idx_notif_user_unread ON notifications(user_id, read_at)" + " WHERE read_at IS NULL" + ) + + +def downgrade() -> None: + op.drop_table("notifications") + op.drop_table("note_shares") + op.drop_table("project_shares") + op.drop_table("group_memberships") + op.drop_table("groups") diff --git a/alembic/versions/0026_add_briefing_tables.py b/alembic/versions/0026_add_briefing_tables.py new file mode 100644 index 0000000..00120ff --- /dev/null +++ b/alembic/versions/0026_add_briefing_tables.py @@ -0,0 +1,75 @@ +"""Add briefing tables: rss_feeds, rss_items, weather_cache, conversation briefing columns.""" + +from alembic import op + +revision = "0026" +down_revision = "0025" + + +def upgrade() -> None: + # Extend conversations table + op.execute(""" + ALTER TABLE conversations + ADD COLUMN IF NOT EXISTS conversation_type TEXT NOT NULL DEFAULT 'chat', + ADD COLUMN IF NOT EXISTS briefing_date DATE + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_conversations_type ON conversations(conversation_type)") + + # RSS feeds + op.execute(""" + CREATE TABLE IF NOT EXISTS rss_feeds ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + category TEXT, + last_fetched_at TIMESTAMPTZ, + CONSTRAINT uq_rss_feeds_user_url UNIQUE (user_id, url) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_rss_feeds_user_id ON rss_feeds(user_id)") + + # RSS items + op.execute(""" + CREATE TABLE IF NOT EXISTS rss_items ( + id SERIAL PRIMARY KEY, + feed_id INTEGER NOT NULL REFERENCES rss_feeds(id) ON DELETE CASCADE, + guid TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + published_at TIMESTAMPTZ, + content TEXT NOT NULL DEFAULT '', + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_rss_items_feed_guid UNIQUE (feed_id, guid) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_rss_items_feed_id ON rss_items(feed_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_rss_items_published_at ON rss_items(published_at)") + + # Weather cache + op.execute(""" + CREATE TABLE IF NOT EXISTS weather_cache ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + location_key TEXT NOT NULL, + location_label TEXT NOT NULL DEFAULT '', + forecast_json JSONB, + previous_json JSONB, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_weather_cache_user_location UNIQUE (user_id, location_key) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_weather_cache_user_id ON weather_cache(user_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_weather_cache_user_id") + op.execute("DROP TABLE IF EXISTS weather_cache") + op.execute("DROP INDEX IF EXISTS ix_rss_items_published_at") + op.execute("DROP INDEX IF EXISTS ix_rss_items_feed_id") + op.execute("DROP TABLE IF EXISTS rss_items") + op.execute("DROP INDEX IF EXISTS ix_rss_feeds_user_id") + op.execute("DROP TABLE IF EXISTS rss_feeds") + op.execute("DROP INDEX IF EXISTS ix_conversations_type") + op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS briefing_date") + op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS conversation_type") diff --git a/docs/plans/2026-03-11-backup-rewrite.md b/docs/plans/2026-03-11-backup-rewrite.md new file mode 100644 index 0000000..1c65dfb --- /dev/null +++ b/docs/plans/2026-03-11-backup-rewrite.md @@ -0,0 +1,538 @@ +# Backup Service Rewrite Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite `services/backup.py` so that full and user backup/restore correctly includes every model added since the original implementation (projects, milestones, task logs, drafts, versions), with correct FK re-mapping on restore. + +**Architecture:** Bump backup JSON format to version 2. Export is additive — all new tables exported. Restore builds an ID map for each table and patches FK references in the correct dependency order. V1 backups continue to restore via the existing code path. + +**Tech Stack:** Python/SQLAlchemy 2.0 async, no new dependencies. + +**Spec:** `docs/superpowers/specs/2026-03-11-backup-rewrite-design.md` + +**Dependency note:** This plan can be split into Part A (pre-sharing models) and Part B (sharing models). Part A is independent and should be done first. + +--- + +## Task 1: Extend export — full backup + +**Files:** +- Modify: `src/fabledassistant/services/backup.py` + +- [ ] **Step 1: Read the current `export_full_backup()` function** + +Understand what is currently exported: users, notes (partial fields), conversations+messages, settings. + +- [ ] **Step 2: Rewrite `export_full_backup()` to version 2** + +Add all missing models to the import list at the top of `backup.py`: +```python +from fabledassistant.models.project import Project +from fabledassistant.models.milestone import Milestone +from fabledassistant.models.task_log import TaskLog +from fabledassistant.models.note_draft import NoteDraft +from fabledassistant.models.note_version import NoteVersion +from fabledassistant.models.push_subscription import PushSubscription +``` + +Rewrite `export_full_backup()`: +```python +async def export_full_backup() -> dict: + async with async_session() as session: + users = (await session.execute(select(User))).scalars().all() + projects = (await session.execute(select(Project))).scalars().all() + milestones = (await session.execute(select(Milestone))).scalars().all() + notes = (await session.execute(select(Note))).scalars().all() + task_logs = (await session.execute(select(TaskLog))).scalars().all() + note_drafts = (await session.execute(select(NoteDraft))).scalars().all() + note_versions = (await session.execute(select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.version_number))).scalars().all() + conversations = (await session.execute( + select(Conversation).options(selectinload(Conversation.messages)) + )).scalars().all() + settings = (await session.execute(select(Setting))).scalars().all() + push_subs = (await session.execute(select(PushSubscription))).scalars().all() + + return { + "version": 2, + "scope": "full", + "exported_at": datetime.now(timezone.utc).isoformat(), + "_security_notice": ( + "This backup contains hashed passwords and push subscription keys. " + "Store it securely and restrict access." + ), + "users": [ + { + "id": u.id, + "username": u.username, + "email": u.email, + "password_hash": u.password_hash, + "oauth_sub": u.oauth_sub, + "role": u.role, + "session_version": u.session_version, + "created_at": u.created_at.isoformat(), + } + for u in users + ], + "projects": [ + { + "id": p.id, + "user_id": p.user_id, + "title": p.title, + "description": p.description, + "goal": p.goal, + "status": p.status, + "color": p.color, + "created_at": p.created_at.isoformat(), + "updated_at": p.updated_at.isoformat(), + } + for p in projects + ], + "milestones": [ + { + "id": m.id, + "user_id": m.user_id, + "project_id": m.project_id, + "title": m.title, + "description": m.description, + "status": m.status, + "order_index": m.order_index, + "created_at": m.created_at.isoformat(), + "updated_at": m.updated_at.isoformat(), + } + for m in milestones + ], + "notes": [ + { + "id": n.id, + "user_id": n.user_id, + "title": n.title, + "body": n.body, + "tags": n.tags or [], + "parent_id": n.parent_id, + "project_id": n.project_id, + "milestone_id": n.milestone_id, + "is_task": n.is_task, + "status": n.status, + "priority": n.priority, + "due_date": n.due_date.isoformat() if n.due_date else None, + "is_starred": getattr(n, "is_starred", False), + "is_pinned": getattr(n, "is_pinned", False), + "created_at": n.created_at.isoformat(), + "updated_at": n.updated_at.isoformat(), + } + for n in notes + ], + "task_logs": [ + { + "id": tl.id, + "user_id": tl.user_id, + "note_id": tl.note_id, + "description": tl.description, + "duration_minutes": tl.duration_minutes, + "logged_at": tl.logged_at.isoformat() if tl.logged_at else None, + "created_at": tl.created_at.isoformat(), + } + for tl in task_logs + ], + "note_drafts": [ + { + "id": nd.id, + "user_id": nd.user_id, + "note_id": nd.note_id, + "title": nd.title, + "body": nd.body, + "saved_at": nd.saved_at.isoformat() if nd.saved_at else None, + } + for nd in note_drafts + ], + "note_versions": [ + { + "id": nv.id, + "user_id": nv.user_id, + "note_id": nv.note_id, + "title": nv.title, + "body": nv.body, + "version_number": nv.version_number, + "created_at": nv.created_at.isoformat(), + } + for nv in note_versions + ], + "conversations": [ + { + "id": c.id, + "user_id": c.user_id, + "title": c.title, + "created_at": c.created_at.isoformat(), + "updated_at": c.updated_at.isoformat(), + "messages": [ + { + "id": m.id, + "role": m.role, + "content": m.content, + "context_note_id": m.context_note_id, + "created_at": m.created_at.isoformat(), + } + for m in c.messages + ], + } + for c in conversations + ], + "settings": [ + {"user_id": s.user_id, "key": s.key, "value": s.value} + for s in settings + ], + "push_subscriptions": [ + { + "user_id": ps.user_id, + "endpoint": ps.endpoint, + "p256dh": ps.p256dh, + "auth": ps.auth, + "created_at": ps.created_at.isoformat(), + } + for ps in push_subs + ], + } +``` + +Check what exact field names exist on each model before assuming. Use `getattr(obj, "field", default)` for fields that may not exist on older DB versions. + +- [ ] **Step 3: Commit** + +```bash +git add src/fabledassistant/services/backup.py +git commit -m "feat(backup): v2 full export with projects, milestones, task_logs, drafts, versions" +``` + +--- + +## Task 2: Extend export — user backup + +**Files:** +- Modify: `src/fabledassistant/services/backup.py` + +- [ ] **Step 1: Rewrite `export_user_backup(user_id)`** + +Same structure as full backup but filtered to `WHERE user_id = uid`. Apply same field additions. Omit sensitive fields (password_hash, oauth_sub) from user self-export. + +```python +async def export_user_backup(user_id: int) -> dict: + async with async_session() as session: + user = await session.get(User, user_id) + projects = (await session.execute(select(Project).where(Project.user_id == user_id))).scalars().all() + milestones = (await session.execute(select(Milestone).where(Milestone.user_id == user_id))).scalars().all() + notes = (await session.execute(select(Note).where(Note.user_id == user_id))).scalars().all() + task_logs = (await session.execute(select(TaskLog).where(TaskLog.user_id == user_id))).scalars().all() + note_drafts = (await session.execute(select(NoteDraft).where(NoteDraft.user_id == user_id))).scalars().all() + note_versions = (await session.execute( + select(NoteVersion).where(NoteVersion.user_id == user_id) + .order_by(NoteVersion.note_id, NoteVersion.version_number) + )).scalars().all() + conversations = (await session.execute( + select(Conversation).options(selectinload(Conversation.messages)) + .where(Conversation.user_id == user_id) + )).scalars().all() + settings = (await session.execute(select(Setting).where(Setting.user_id == user_id))).scalars().all() + + return { + "version": 2, + "scope": "user", + "exported_at": datetime.now(timezone.utc).isoformat(), + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + "role": user.role, + "created_at": user.created_at.isoformat(), + } if user else None, + "projects": [...], # same as full but user-filtered + "milestones": [...], + "notes": [...], + "task_logs": [...], + "note_drafts": [...], + "note_versions": [...], + "conversations": [...], + "settings": [...], + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/fabledassistant/services/backup.py +git commit -m "feat(backup): v2 user backup with all models" +``` + +--- + +## Task 3: Rewrite restore for v2 + +**Files:** +- Modify: `src/fabledassistant/services/backup.py` + +- [ ] **Step 1: Add version dispatch to `restore_full_backup`** + +```python +async def restore_full_backup(data: dict) -> dict: + version = data.get("version", 1) + if version == 1: + return await _restore_v1(data) + return await _restore_v2(data) +``` + +Move existing restore code into `_restore_v1(data)` with no changes. + +- [ ] **Step 2: Implement `_restore_v2(data)` with full FK re-mapping** + +Restore order (respects FK dependencies): +1. Users → build `user_id_map` +2. Projects (fk: user_id) → build `project_id_map` +3. Milestones (fk: user_id, project_id) → build `milestone_id_map` +4. Notes — first pass: insert without `parent_id` (fk: user_id, project_id, milestone_id) → build `note_id_map` +5. Notes — second pass: patch `parent_id` using `note_id_map` +6. TaskLogs (fk: user_id, note_id via note_id_map) +7. NoteDrafts (fk: user_id, note_id via note_id_map) +8. NoteVersions (fk: user_id, note_id via note_id_map) — export only, no restore by default (skip unless flag set) +9. Conversations (fk: user_id) → Messages (fk: conversation_id, context_note_id via note_id_map) +10. Settings (fk: user_id) + +```python +async def _restore_v2(data: dict) -> dict: + from datetime import date, datetime, timezone + + stats = { + "users": 0, "projects": 0, "milestones": 0, "notes": 0, + "task_logs": 0, "note_drafts": 0, "conversations": 0, + "messages": 0, "settings": 0 + } + + async with async_session() as session: + user_id_map: dict[int, int] = {} + project_id_map: dict[int, int] = {} + milestone_id_map: dict[int, int] = {} + note_id_map: dict[int, int] = {} + + # 1. Users + for u_data in data.get("users", []): + old_id = u_data["id"] + user = User( + username=u_data["username"], + email=u_data.get("email"), + password_hash=u_data.get("password_hash"), + oauth_sub=u_data.get("oauth_sub"), + role=u_data.get("role", "user"), + session_version=u_data.get("session_version", 1), + created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc), + ) + session.add(user) + await session.flush() + user_id_map[old_id] = user.id + stats["users"] += 1 + + # 2. Projects + for p_data in data.get("projects", []): + mapped_uid = user_id_map.get(p_data.get("user_id", 0)) + if mapped_uid is None: + continue + proj = Project( + user_id=mapped_uid, + title=p_data.get("title", ""), + description=p_data.get("description"), + goal=p_data.get("goal"), + status=p_data.get("status", "active"), + color=p_data.get("color"), + created_at=datetime.fromisoformat(p_data["created_at"]) if p_data.get("created_at") else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(p_data["updated_at"]) if p_data.get("updated_at") else datetime.now(timezone.utc), + ) + session.add(proj) + await session.flush() + project_id_map[p_data["id"]] = proj.id + stats["projects"] += 1 + + # 3. Milestones + for m_data in data.get("milestones", []): + mapped_uid = user_id_map.get(m_data.get("user_id", 0)) + mapped_pid = project_id_map.get(m_data.get("project_id", 0)) + if mapped_uid is None: + continue + ms = Milestone( + user_id=mapped_uid, + project_id=mapped_pid, + title=m_data.get("title", ""), + description=m_data.get("description"), + status=m_data.get("status", "open"), + order_index=m_data.get("order_index", 0), + created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(m_data["updated_at"]) if m_data.get("updated_at") else datetime.now(timezone.utc), + ) + session.add(ms) + await session.flush() + milestone_id_map[m_data["id"]] = ms.id + stats["milestones"] += 1 + + # 4. Notes — first pass (no parent_id) + note_objects: list[tuple[int, int]] = [] # (old_parent_id, new_note_id) + for n_data in data.get("notes", []): + mapped_uid = user_id_map.get(n_data.get("user_id", 0)) + if mapped_uid is None: + continue + due = None + if n_data.get("due_date"): + due = date.fromisoformat(n_data["due_date"]) + note = Note( + user_id=mapped_uid, + title=n_data.get("title", ""), + body=n_data.get("body", ""), + tags=n_data.get("tags", []), + parent_id=None, # patched in second pass + project_id=project_id_map.get(n_data.get("project_id")) if n_data.get("project_id") else None, + milestone_id=milestone_id_map.get(n_data.get("milestone_id")) if n_data.get("milestone_id") else None, + is_task=n_data.get("is_task", False), + status=n_data.get("status"), + priority=n_data.get("priority"), + due_date=due, + created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc), + ) + session.add(note) + await session.flush() + note_id_map[n_data["id"]] = note.id + if n_data.get("parent_id"): + note_objects.append((n_data["parent_id"], note.id)) + stats["notes"] += 1 + + # 5. Notes — second pass: patch parent_id + for old_parent_id, new_note_id in note_objects: + new_parent_id = note_id_map.get(old_parent_id) + if new_parent_id: + note_row = await session.get(Note, new_note_id) + if note_row: + note_row.parent_id = new_parent_id + + # 6. TaskLogs + for tl_data in data.get("task_logs", []): + mapped_uid = user_id_map.get(tl_data.get("user_id", 0)) + mapped_nid = note_id_map.get(tl_data.get("note_id", 0)) + if mapped_uid is None or mapped_nid is None: + continue + from fabledassistant.models.task_log import TaskLog + tl = TaskLog( + user_id=mapped_uid, + note_id=mapped_nid, + description=tl_data.get("description", ""), + duration_minutes=tl_data.get("duration_minutes"), + logged_at=datetime.fromisoformat(tl_data["logged_at"]) if tl_data.get("logged_at") else None, + created_at=datetime.fromisoformat(tl_data["created_at"]) if tl_data.get("created_at") else datetime.now(timezone.utc), + ) + session.add(tl) + stats["task_logs"] += 1 + + # 7. NoteDrafts + for nd_data in data.get("note_drafts", []): + mapped_uid = user_id_map.get(nd_data.get("user_id", 0)) + mapped_nid = note_id_map.get(nd_data.get("note_id", 0)) + if mapped_uid is None or mapped_nid is None: + continue + from fabledassistant.models.note_draft import NoteDraft + nd = NoteDraft( + user_id=mapped_uid, + note_id=mapped_nid, + title=nd_data.get("title", ""), + body=nd_data.get("body", ""), + saved_at=datetime.fromisoformat(nd_data["saved_at"]) if nd_data.get("saved_at") else None, + ) + session.add(nd) + stats["note_drafts"] += 1 + + # 8. Conversations + Messages + for c_data in data.get("conversations", []): + mapped_uid = user_id_map.get(c_data.get("user_id", 0)) + if mapped_uid is None: + continue + conv = Conversation( + user_id=mapped_uid, + title=c_data.get("title", ""), + created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc), + ) + session.add(conv) + await session.flush() + stats["conversations"] += 1 + for m_data in c_data.get("messages", []): + msg = Message( + conversation_id=conv.id, + role=m_data["role"], + content=m_data.get("content", ""), + context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None, + created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc), + ) + session.add(msg) + stats["messages"] += 1 + + # 9. Settings + for s_data in data.get("settings", []): + mapped_uid = user_id_map.get(s_data.get("user_id", 0)) + if mapped_uid is None: + continue + setting = Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", "")) + session.add(setting) + stats["settings"] += 1 + + await session.commit() + + logger.info("Restored v2 backup: %s", stats) + return stats +``` + +- [ ] **Step 3: Add missing imports at top of file** + +```python +from datetime import datetime, timezone +from fabledassistant.models.project import Project +from fabledassistant.models.milestone import Milestone +``` + +- [ ] **Step 4: Test restore round-trip** + +```bash +# Export +curl -s -b cookies.txt "http://localhost:5000/api/admin/backup?scope=full" -o /tmp/backup_v2.json +# Inspect structure +python3 -c "import json; d=json.load(open('/tmp/backup_v2.json')); print(list(d.keys()))" +# Expected keys: version, scope, exported_at, users, projects, milestones, notes, task_logs, ... +``` + +- [ ] **Step 5: Typecheck** + +```bash +docker compose exec app python -m py_compile src/fabledassistant/services/backup.py +``` +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/services/backup.py +git commit -m "feat(backup): v2 restore with full FK re-mapping; v1 restore preserved" +``` + +--- + +## Task 4: Verification + +- [ ] **Step 1: Full round-trip test** + +1. Export full backup as admin +2. Verify JSON has `"version": 2` and all expected top-level keys +3. Verify notes have `project_id`, `milestone_id`, `is_task` fields +4. Verify `task_logs` array has entries (create a task log entry first if needed) +5. Restore backup to a clean test instance (or verify restore code path runs without error in dry-run) + +- [ ] **Step 2: V1 backward compat test** + +Take an old v1 backup JSON (or construct one without the `version` key) and run restore. Verify it takes the v1 path and doesn't error. + +- [ ] **Step 3: Commit final verification note** + +```bash +git commit --allow-empty -m "chore(backup): v2 backup rewrite verified" +``` diff --git a/docs/plans/2026-03-11-sharing-collaboration.md b/docs/plans/2026-03-11-sharing-collaboration.md new file mode 100644 index 0000000..c24cea7 --- /dev/null +++ b/docs/plans/2026-03-11-sharing-collaboration.md @@ -0,0 +1,2023 @@ +# Sharing & Collaboration Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add groups, project/note sharing, access control, and notifications so multiple users can collaborate on content. + +**Architecture:** New `services/access.py` is the single source of truth for permission resolution. Existing owner-scoped service functions are preserved (used by LLM tools); new `_for_user` variants add shared-access checks for human-facing routes. Groups are platform-wide with owner/member roles. Notifications fire in-app, push, and email on share/group events. + +**Tech Stack:** Python/Quart/SQLAlchemy 2.0 async, Alembic, Vue 3 TypeScript, Pinia, existing push/email infrastructure. + +**Spec:** `docs/superpowers/specs/2026-03-11-sharing-collaboration-design.md` + +--- + +## Chunk 1: Database + Models + +### Task 1: Alembic migration for all new tables + +**Files:** +- Create: `alembic/versions/0024_add_sharing_and_notifications.py` + +- [ ] **Step 1: Write migration** + +```python +"""Add groups, sharing, notifications tables + +Revision ID: 0024_add_sharing_and_notifications +Revises: +Create Date: 2026-03-11 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '0024_add_sharing_and_notifications' +down_revision = '' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'groups', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.Text(), nullable=False, unique=True), + sa.Column('description', sa.Text()), + sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL')), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + op.create_table( + 'group_memberships', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('group_id', sa.Integer(), sa.ForeignKey('groups.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('role', sa.Text(), nullable=False, server_default='member'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint('group_id', 'user_id', name='uq_gm_group_user'), + ) + op.create_index('idx_gm_user', 'group_memberships', ['user_id']) + + op.create_table( + 'project_shares', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False), + sa.Column('shared_with_user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE')), + sa.Column('shared_with_group_id', sa.Integer(), sa.ForeignKey('groups.id', ondelete='CASCADE')), + sa.Column('permission', sa.Text(), nullable=False), + sa.Column('invited_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ps_exclusive_target" + ), + ) + op.create_index('idx_ps_project', 'project_shares', ['project_id']) + # Partial unique indexes via raw SQL + op.execute("CREATE UNIQUE INDEX idx_ps_user ON project_shares(project_id, shared_with_user_id) WHERE shared_with_user_id IS NOT NULL") + op.execute("CREATE UNIQUE INDEX idx_ps_group ON project_shares(project_id, shared_with_group_id) WHERE shared_with_group_id IS NOT NULL") + + op.create_table( + 'note_shares', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('note_id', sa.Integer(), sa.ForeignKey('notes.id', ondelete='CASCADE'), nullable=False), + sa.Column('shared_with_user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE')), + sa.Column('shared_with_group_id', sa.Integer(), sa.ForeignKey('groups.id', ondelete='CASCADE')), + sa.Column('permission', sa.Text(), nullable=False), + sa.Column('invited_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ns_exclusive_target" + ), + ) + op.execute("CREATE UNIQUE INDEX idx_ns_user ON note_shares(note_id, shared_with_user_id) WHERE shared_with_user_id IS NOT NULL") + op.execute("CREATE UNIQUE INDEX idx_ns_group ON note_shares(note_id, shared_with_group_id) WHERE shared_with_group_id IS NOT NULL") + + op.create_table( + 'notifications', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('type', sa.Text(), nullable=False), + sa.Column('payload', postgresql.JSONB(), nullable=False, server_default='{}'), + sa.Column('read_at', sa.DateTime(timezone=True)), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.execute("CREATE INDEX idx_notif_user_unread ON notifications(user_id, read_at) WHERE read_at IS NULL") + + +def downgrade() -> None: + op.drop_table('notifications') + op.drop_table('note_shares') + op.drop_table('project_shares') + op.drop_table('group_memberships') + op.drop_table('groups') +``` + +- [ ] **Step 2: Find the previous revision ID and fill `down_revision`** + +```bash +docker compose exec app alembic heads +``` + +- [ ] **Step 3: Run migration** + +```bash +docker compose exec app alembic upgrade head +``` +Expected: No errors; 5 new tables present. + +- [ ] **Step 4: Commit** + +```bash +git add alembic/versions/0024_add_sharing_and_notifications.py +git commit -m "feat: add groups, sharing, notifications migrations" +``` + +--- + +### Task 2: SQLAlchemy models + +**Files:** +- Create: `src/fabledassistant/models/group.py` +- Create: `src/fabledassistant/models/share.py` +- Create: `src/fabledassistant/models/notification.py` +- Modify: `src/fabledassistant/models/__init__.py` + +- [ ] **Step 1: Write `models/group.py`** + +```python +from datetime import datetime, timezone +from sqlalchemy import Integer, Text, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from fabledassistant.models import Base + + +class Group(Base): + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + description: Mapped[str | None] = mapped_column(Text) + created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + memberships: Mapped[list["GroupMembership"]] = relationship("GroupMembership", back_populates="group", cascade="all, delete-orphan") + + def to_dict(self) -> dict: + return {"id": self.id, "name": self.name, "description": self.description, + "created_by": self.created_by, "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat()} + + +class GroupMembership(Base): + __tablename__ = "group_memberships" + __table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_gm_group_user"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + group_id: Mapped[int] = mapped_column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + role: Mapped[str] = mapped_column(Text, nullable=False, default="member") # 'owner' | 'member' + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + group: Mapped["Group"] = relationship("Group", back_populates="memberships") + + def to_dict(self) -> dict: + return {"id": self.id, "group_id": self.group_id, "user_id": self.user_id, + "role": self.role, "created_at": self.created_at.isoformat()} +``` + +- [ ] **Step 2: Write `models/share.py`** + +```python +from datetime import datetime, timezone +from sqlalchemy import Integer, Text, DateTime, ForeignKey, CheckConstraint +from sqlalchemy.orm import Mapped, mapped_column +from fabledassistant.models import Base + + +class ProjectShare(Base): + __tablename__ = "project_shares" + __table_args__ = ( + CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ps_exclusive_target" + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + shared_with_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) + shared_with_group_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("groups.id", ondelete="CASCADE")) + permission: Mapped[str] = mapped_column(Text, nullable=False) # viewer | editor | admin + invited_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return {"id": self.id, "project_id": self.project_id, + "shared_with_user_id": self.shared_with_user_id, + "shared_with_group_id": self.shared_with_group_id, + "permission": self.permission, "invited_by": self.invited_by, + "created_at": self.created_at.isoformat()} + + +class NoteShare(Base): + __tablename__ = "note_shares" + __table_args__ = ( + CheckConstraint( + "(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)", + name="ck_ns_exclusive_target" + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + note_id: Mapped[int] = mapped_column(Integer, ForeignKey("notes.id", ondelete="CASCADE"), nullable=False) + shared_with_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) + shared_with_group_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("groups.id", ondelete="CASCADE")) + permission: Mapped[str] = mapped_column(Text, nullable=False) + invited_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return {"id": self.id, "note_id": self.note_id, + "shared_with_user_id": self.shared_with_user_id, + "shared_with_group_id": self.shared_with_group_id, + "permission": self.permission, "invited_by": self.invited_by, + "created_at": self.created_at.isoformat()} +``` + +- [ ] **Step 3: Write `models/notification.py`** + +```python +from datetime import datetime, timezone +from sqlalchemy import Integer, Text, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column +from fabledassistant.models import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + type: Mapped[str] = mapped_column(Text, nullable=False) # project_shared | note_shared | group_added + payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return {"id": self.id, "user_id": self.user_id, "type": self.type, + "payload": self.payload, "read_at": self.read_at.isoformat() if self.read_at else None, + "created_at": self.created_at.isoformat()} +``` + +- [ ] **Step 4: Register models in `models/__init__.py`** + +Add to the imports section: +```python +from fabledassistant.models.group import Group, GroupMembership # noqa: F401 +from fabledassistant.models.share import ProjectShare, NoteShare # noqa: F401 +from fabledassistant.models.notification import Notification # noqa: F401 +``` + +- [ ] **Step 5: Typecheck** + +```bash +cd frontend && npx vue-tsc --noEmit; cd .. && python -m py_compile src/fabledassistant/models/group.py src/fabledassistant/models/share.py src/fabledassistant/models/notification.py +``` +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/models/ +git commit -m "feat: add Group, Share, Notification SQLAlchemy models" +``` + +--- + +## Chunk 2: Access Control + Groups + +### Task 3: Access control service + +**Files:** +- Create: `src/fabledassistant/services/access.py` +- Create: `tests/test_access.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_access.py +import pytest +from unittest.mock import AsyncMock, patch + +@pytest.mark.asyncio +async def test_owner_gets_owner_permission(): + """Project owner always gets 'owner' permission.""" + ... + +@pytest.mark.asyncio +async def test_direct_share_grants_permission(): + """User with direct ProjectShare gets that permission.""" + ... + +@pytest.mark.asyncio +async def test_group_share_grants_permission(): + """User who is a group member gets the group's share permission.""" + ... + +@pytest.mark.asyncio +async def test_highest_permission_wins(): + """When user has viewer directly + editor via group, returns editor.""" + ... + +@pytest.mark.asyncio +async def test_no_access_returns_none(): + """Unrelated user gets None.""" + ... + +@pytest.mark.asyncio +async def test_note_inherits_project_permission(): + """Note in a shared project inherits project permission.""" + ... + +@pytest.mark.asyncio +async def test_note_share_overrides_project(): + """Note-level admin share overrides project viewer share.""" + ... +``` + +Run: `docker compose exec app python -m pytest tests/test_access.py -v` +Expected: FAIL (functions don't exist yet) + +- [ ] **Step 2: Implement `services/access.py`** + +```python +import logging +from sqlalchemy import select +from fabledassistant.models import async_session +from fabledassistant.models.note import Note +from fabledassistant.models.project import Project +from fabledassistant.models.share import NoteShare, ProjectShare +from fabledassistant.models.group import GroupMembership + +logger = logging.getLogger(__name__) + +PERMISSION_RANK = {"viewer": 1, "editor": 2, "admin": 3, "owner": 4} + + +def _higher(a: str | None, b: str | None) -> str | None: + """Return the higher permission of two, or None if both None.""" + if a is None: + return b + if b is None: + return a + return a if PERMISSION_RANK[a] >= PERMISSION_RANK[b] else b + + +async def get_project_permission(user_id: int, project_id: int) -> str | None: + async with async_session() as session: + project = await session.get(Project, project_id) + if project is None: + return None + if project.user_id == user_id: + return "owner" + + # Direct share + direct = (await session.execute( + select(ProjectShare).where( + ProjectShare.project_id == project_id, + ProjectShare.shared_with_user_id == user_id + ) + )).scalar_one_or_none() + + # Group shares: find user's groups, then matching shares + user_group_ids = (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + + group_perm: str | None = None + if user_group_ids: + group_shares = (await session.execute( + select(ProjectShare).where( + ProjectShare.project_id == project_id, + ProjectShare.shared_with_group_id.in_(user_group_ids) + ) + )).scalars().all() + for gs in group_shares: + group_perm = _higher(group_perm, gs.permission) + + return _higher(direct.permission if direct else None, group_perm) + + +async def can_read_project(user_id: int, project_id: int) -> bool: + return (await get_project_permission(user_id, project_id)) is not None + + +async def can_write_project(user_id: int, project_id: int) -> bool: + perm = await get_project_permission(user_id, project_id) + return perm in ("editor", "admin", "owner") + + +async def can_admin_project(user_id: int, project_id: int) -> bool: + perm = await get_project_permission(user_id, project_id) + return perm in ("admin", "owner") + + +async def get_note_permission(user_id: int, note_id: int) -> str | None: + async with async_session() as session: + note = await session.get(Note, note_id) + if note is None: + return None + if note.user_id == user_id: + return "owner" + + # Direct note share + direct = (await session.execute( + select(NoteShare).where( + NoteShare.note_id == note_id, + NoteShare.shared_with_user_id == user_id + ) + )).scalar_one_or_none() + + user_group_ids = (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + + group_perm: str | None = None + if user_group_ids: + group_shares = (await session.execute( + select(NoteShare).where( + NoteShare.note_id == note_id, + NoteShare.shared_with_group_id.in_(user_group_ids) + ) + )).scalars().all() + for gs in group_shares: + group_perm = _higher(group_perm, gs.permission) + + note_perm = _higher(direct.permission if direct else None, group_perm) + + # Inherit from project if note belongs to one + if note.project_id is not None: + project_perm = await get_project_permission(user_id, note.project_id) + note_perm = _higher(note_perm, project_perm) + + return note_perm + + +async def can_read_note(user_id: int, note_id: int) -> bool: + return (await get_note_permission(user_id, note_id)) is not None + + +async def can_write_note(user_id: int, note_id: int) -> bool: + perm = await get_note_permission(user_id, note_id) + return perm in ("editor", "admin", "owner") +``` + +- [ ] **Step 3: Run tests** + +```bash +docker compose exec app python -m pytest tests/test_access.py -v +``` +Expected: All 7 tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/fabledassistant/services/access.py tests/test_access.py +git commit -m "feat: access control service with permission resolution" +``` + +--- + +### Task 4: Groups service + routes + +**Files:** +- Create: `src/fabledassistant/services/groups.py` +- Create: `src/fabledassistant/routes/groups.py` +- Modify: `src/fabledassistant/app.py` + +- [ ] **Step 1: Implement `services/groups.py`** + +```python +import logging +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from fabledassistant.models import async_session +from fabledassistant.models.group import Group, GroupMembership +from fabledassistant.models.user import User + +logger = logging.getLogger(__name__) + + +async def create_group(user_id: int, name: str, description: str | None = None) -> Group: + async with async_session() as session: + group = Group(name=name, description=description, created_by=user_id) + session.add(group) + await session.flush() + membership = GroupMembership(group_id=group.id, user_id=user_id, role="owner") + session.add(membership) + await session.commit() + await session.refresh(group) + return group + + +async def list_groups(user_id: int, is_admin: bool = False) -> list[dict]: + """All users see all groups (with member count). Admin flag has no effect currently.""" + async with async_session() as session: + groups = (await session.execute(select(Group).order_by(Group.name))).scalars().all() + user_group_ids = set( + (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + ) + result = [] + for g in groups: + count = len((await session.execute( + select(GroupMembership).where(GroupMembership.group_id == g.id) + )).scalars().all()) + d = g.to_dict() + d["member_count"] = count + d["is_member"] = g.id in user_group_ids + result.append(d) + return result + + +async def get_group(group_id: int) -> Group | None: + async with async_session() as session: + return await session.get(Group, group_id) + + +async def update_group(acting_user_id: int, group_id: int, is_site_admin: bool, **fields) -> Group | None: + async with async_session() as session: + group = await session.get(Group, group_id) + if not group: + return None + # Must be group owner or site admin + if not is_site_admin: + membership = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == acting_user_id, + GroupMembership.role == "owner" + ) + )).scalar_one_or_none() + if not membership: + return None # caller treats as 403 + for k, v in fields.items(): + if hasattr(group, k): + setattr(group, k, v) + await session.commit() + await session.refresh(group) + return group + + +async def delete_group(acting_user_id: int, group_id: int, is_site_admin: bool) -> bool: + async with async_session() as session: + group = await session.get(Group, group_id) + if not group: + return False + if not is_site_admin and group.created_by != acting_user_id: + return False + await session.delete(group) + await session.commit() + return True + + +async def list_members(group_id: int) -> list[dict]: + async with async_session() as session: + rows = (await session.execute( + select(GroupMembership, User) + .join(User, User.id == GroupMembership.user_id) + .where(GroupMembership.group_id == group_id) + )).all() + return [ + {**m.to_dict(), "username": u.username, "email": u.email} + for m, u in rows + ] + + +async def add_member(acting_user_id: int, group_id: int, target_user_id: int, role: str, is_site_admin: bool) -> GroupMembership | None: + async with async_session() as session: + if not is_site_admin: + acting_membership = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == acting_user_id, + GroupMembership.role == "owner" + ) + )).scalar_one_or_none() + if not acting_membership: + return None + membership = GroupMembership(group_id=group_id, user_id=target_user_id, role=role) + session.add(membership) + await session.commit() + await session.refresh(membership) + return membership + + +async def update_member_role(acting_user_id: int, group_id: int, target_user_id: int, role: str, is_site_admin: bool) -> GroupMembership | None: + async with async_session() as session: + if not is_site_admin: + acting_m = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == acting_user_id, + GroupMembership.role == "owner" + ) + )).scalar_one_or_none() + if not acting_m: + return None + m = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == target_user_id + ) + )).scalar_one_or_none() + if not m: + return None + m.role = role + await session.commit() + await session.refresh(m) + return m + + +async def remove_member(acting_user_id: int, group_id: int, target_user_id: int, is_site_admin: bool) -> bool: + """Group owner, site admin, or self-remove are allowed.""" + async with async_session() as session: + is_self = acting_user_id == target_user_id + if not is_site_admin and not is_self: + acting_m = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == acting_user_id, + GroupMembership.role == "owner" + ) + )).scalar_one_or_none() + if not acting_m: + return False + m = (await session.execute( + select(GroupMembership).where( + GroupMembership.group_id == group_id, + GroupMembership.user_id == target_user_id + ) + )).scalar_one_or_none() + if not m: + return False + await session.delete(m) + await session.commit() + return True + + +async def get_user_groups(user_id: int) -> list[Group]: + async with async_session() as session: + rows = (await session.execute( + select(Group).join(GroupMembership, GroupMembership.group_id == Group.id) + .where(GroupMembership.user_id == user_id) + )).scalars().all() + return list(rows) +``` + +- [ ] **Step 2: Implement `routes/groups.py`** + +```python +from quart import Blueprint, g, jsonify, request +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services import groups as group_svc + +groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups") + + +@groups_bp.route("", methods=["GET"]) +@login_required +async def list_groups(): + uid = get_current_user_id() + is_admin = g.user.role == "admin" + result = await group_svc.list_groups(uid, is_admin) + return jsonify({"groups": result}) + + +@groups_bp.route("", methods=["POST"]) +@login_required +async def create_group(): + uid = get_current_user_id() + data = await request.get_json() + name = (data.get("name") or "").strip() + if not name: + return jsonify({"error": "Name is required"}), 400 + try: + group = await group_svc.create_group(uid, name, data.get("description")) + except Exception: + return jsonify({"error": "Group name already taken"}), 409 + return jsonify(group.to_dict()), 201 + + +@groups_bp.route("/", methods=["GET"]) +@login_required +async def get_group(group_id: int): + group = await group_svc.get_group(group_id) + if not group: + return jsonify({"error": "Not found"}), 404 + members = await group_svc.list_members(group_id) + return jsonify({**group.to_dict(), "members": members}) + + +@groups_bp.route("/", methods=["PATCH"]) +@login_required +async def update_group(group_id: int): + uid = get_current_user_id() + data = await request.get_json() + group = await group_svc.update_group(uid, group_id, g.user.role == "admin", + **{k: v for k, v in data.items() if k in ("name", "description")}) + if not group: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify(group.to_dict()) + + +@groups_bp.route("/", methods=["DELETE"]) +@login_required +async def delete_group(group_id: int): + uid = get_current_user_id() + deleted = await group_svc.delete_group(uid, group_id, g.user.role == "admin") + if not deleted: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify({"status": "ok"}) + + +@groups_bp.route("//members", methods=["GET"]) +@login_required +async def list_members(group_id: int): + members = await group_svc.list_members(group_id) + return jsonify({"members": members}) + + +@groups_bp.route("//members", methods=["POST"]) +@login_required +async def add_member(group_id: int): + uid = get_current_user_id() + data = await request.get_json() + target_uid = data.get("user_id") + role = data.get("role", "member") + if not target_uid: + return jsonify({"error": "user_id required"}), 400 + if role not in ("owner", "member"): + return jsonify({"error": "role must be owner or member"}), 400 + membership = await group_svc.add_member(uid, group_id, target_uid, role, g.user.role == "admin") + if not membership: + return jsonify({"error": "Forbidden or group not found"}), 403 + return jsonify(membership.to_dict()), 201 + + +@groups_bp.route("//members/", methods=["PATCH"]) +@login_required +async def update_member(group_id: int, target_uid: int): + uid = get_current_user_id() + data = await request.get_json() + role = data.get("role") + if role not in ("owner", "member"): + return jsonify({"error": "role must be owner or member"}), 400 + m = await group_svc.update_member_role(uid, group_id, target_uid, role, g.user.role == "admin") + if not m: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify(m.to_dict()) + + +@groups_bp.route("//members/", methods=["DELETE"]) +@login_required +async def remove_member(group_id: int, target_uid: int): + uid = get_current_user_id() + removed = await group_svc.remove_member(uid, group_id, target_uid, g.user.role == "admin") + if not removed: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify({"status": "ok"}) +``` + +- [ ] **Step 3: Register blueprint in `app.py`** + +```python +from fabledassistant.routes.groups import groups_bp +# in create_app(): +app.register_blueprint(groups_bp) +``` + +- [ ] **Step 4: Test manually** + +```bash +docker compose up --build -d +# Create group, add member, list, delete +curl -s -b cookies.txt http://localhost:5000/api/groups +``` +Expected: 200 with empty `groups` list + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/groups.py src/fabledassistant/routes/groups.py src/fabledassistant/app.py +git commit -m "feat: groups service and CRUD routes" +``` + +--- + +## Chunk 3: Sharing + +### Task 5: Sharing service + routes + user search + +**Files:** +- Create: `src/fabledassistant/services/sharing.py` +- Create: `src/fabledassistant/routes/shares.py` +- Create: `src/fabledassistant/routes/users.py` +- Modify: `src/fabledassistant/app.py` + +- [ ] **Step 1: Implement `services/sharing.py`** + +```python +import logging +from sqlalchemy import select +from fabledassistant.models import async_session +from fabledassistant.models.share import ProjectShare, NoteShare +from fabledassistant.models.group import GroupMembership +from fabledassistant.models.user import User +from fabledassistant.models.project import Project +from fabledassistant.models.note import Note + +logger = logging.getLogger(__name__) + +VALID_PERMISSIONS = {"viewer", "editor", "admin"} + + +async def share_project(owner_user_id: int, project_id: int, + target_user_id: int | None = None, + target_group_id: int | None = None, + permission: str = "viewer") -> ProjectShare | None: + if permission not in VALID_PERMISSIONS: + return None + async with async_session() as session: + # Verify caller can admin the project + from fabledassistant.services.access import can_admin_project + if not await can_admin_project(owner_user_id, project_id): + return None + share = ProjectShare(project_id=project_id, + shared_with_user_id=target_user_id, + shared_with_group_id=target_group_id, + permission=permission, invited_by=owner_user_id) + session.add(share) + await session.commit() + await session.refresh(share) + return share + + +async def update_project_share(acting_user_id: int, share_id: int, permission: str) -> ProjectShare | None: + if permission not in VALID_PERMISSIONS: + return None + async with async_session() as session: + share = await session.get(ProjectShare, share_id) + if not share: + return None + from fabledassistant.services.access import can_admin_project + if not await can_admin_project(acting_user_id, share.project_id): + return None + share.permission = permission + await session.commit() + await session.refresh(share) + return share + + +async def remove_project_share(acting_user_id: int, share_id: int) -> bool: + async with async_session() as session: + share = await session.get(ProjectShare, share_id) + if not share: + return False + from fabledassistant.services.access import can_admin_project + if not await can_admin_project(acting_user_id, share.project_id): + return False + await session.delete(share) + await session.commit() + return True + + +async def list_project_shares(project_id: int) -> list[dict]: + async with async_session() as session: + shares = (await session.execute( + select(ProjectShare).where(ProjectShare.project_id == project_id) + )).scalars().all() + result = [] + for s in shares: + d = s.to_dict() + if s.shared_with_user_id: + user = await session.get(User, s.shared_with_user_id) + d["username"] = user.username if user else None + if s.shared_with_group_id: + from fabledassistant.models.group import Group + group = await session.get(Group, s.shared_with_group_id) + d["group_name"] = group.name if group else None + result.append(d) + return result + + +async def share_note(owner_user_id: int, note_id: int, + target_user_id: int | None = None, + target_group_id: int | None = None, + permission: str = "viewer") -> NoteShare | None: + if permission not in VALID_PERMISSIONS: + return None + async with async_session() as session: + from fabledassistant.services.access import can_admin_project, get_note_permission + perm = await get_note_permission(owner_user_id, note_id) + if perm not in ("admin", "owner"): + return None + share = NoteShare(note_id=note_id, shared_with_user_id=target_user_id, + shared_with_group_id=target_group_id, + permission=permission, invited_by=owner_user_id) + session.add(share) + await session.commit() + await session.refresh(share) + return share + + +async def update_note_share(acting_user_id: int, share_id: int, permission: str) -> NoteShare | None: + if permission not in VALID_PERMISSIONS: + return None + async with async_session() as session: + share = await session.get(NoteShare, share_id) + if not share: + return None + from fabledassistant.services.access import get_note_permission + perm = await get_note_permission(acting_user_id, share.note_id) + if perm not in ("admin", "owner"): + return None + share.permission = permission + await session.commit() + await session.refresh(share) + return share + + +async def remove_note_share(acting_user_id: int, share_id: int) -> bool: + async with async_session() as session: + share = await session.get(NoteShare, share_id) + if not share: + return False + from fabledassistant.services.access import get_note_permission + perm = await get_note_permission(acting_user_id, share.note_id) + if perm not in ("admin", "owner"): + return False + await session.delete(share) + await session.commit() + return True + + +async def list_note_shares(note_id: int) -> list[dict]: + async with async_session() as session: + shares = (await session.execute( + select(NoteShare).where(NoteShare.note_id == note_id) + )).scalars().all() + result = [] + for s in shares: + d = s.to_dict() + if s.shared_with_user_id: + user = await session.get(User, s.shared_with_user_id) + d["username"] = user.username if user else None + if s.shared_with_group_id: + from fabledassistant.models.group import Group + group = await session.get(Group, s.shared_with_group_id) + d["group_name"] = group.name if group else None + result.append(d) + return result + + +async def list_shared_with_me(user_id: int) -> dict: + """Returns all projects and notes shared with the user (directly or via group).""" + async with async_session() as session: + # User's groups + user_group_ids = (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + + # Projects: direct shares + proj_shares_direct = (await session.execute( + select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id) + )).scalars().all() + + # Projects: group shares + proj_shares_group = [] + if user_group_ids: + proj_shares_group = (await session.execute( + select(ProjectShare).where(ProjectShare.shared_with_group_id.in_(user_group_ids)) + )).scalars().all() + + seen_project_ids: set[int] = set() + projects = [] + for share in list(proj_shares_direct) + list(proj_shares_group): + if share.project_id in seen_project_ids: + continue + seen_project_ids.add(share.project_id) + proj = await session.get(Project, share.project_id) + if proj: + owner = await session.get(User, proj.user_id) + projects.append({**proj.to_dict() if hasattr(proj, "to_dict") else {"id": proj.id, "title": proj.title}, + "owner_username": owner.username if owner else None, + "permission": share.permission}) + + # Notes: direct + group + note_shares_direct = (await session.execute( + select(NoteShare).where(NoteShare.shared_with_user_id == user_id) + )).scalars().all() + note_shares_group = [] + if user_group_ids: + note_shares_group = (await session.execute( + select(NoteShare).where(NoteShare.shared_with_group_id.in_(user_group_ids)) + )).scalars().all() + + seen_note_ids: set[int] = set() + notes = [] + for share in list(note_shares_direct) + list(note_shares_group): + if share.note_id in seen_note_ids: + continue + seen_note_ids.add(share.note_id) + note = await session.get(Note, share.note_id) + if note: + owner = await session.get(User, note.user_id) + notes.append({"id": note.id, "title": note.title, "is_task": note.is_task, + "project_id": note.project_id, "updated_at": note.updated_at.isoformat(), + "owner_username": owner.username if owner else None, + "permission": share.permission}) + + return {"projects": projects, "notes": notes} +``` + +- [ ] **Step 2: Implement `routes/shares.py`** + +```python +from quart import Blueprint, g, jsonify, request +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services import sharing as share_svc + +shares_bp = Blueprint("shares", __name__) + + +@shares_bp.route("/api/projects//shares", methods=["GET"]) +@login_required +async def list_project_shares(project_id: int): + uid = get_current_user_id() + from fabledassistant.services.access import can_admin_project + if not await can_admin_project(uid, project_id): + return jsonify({"error": "Forbidden"}), 403 + shares = await share_svc.list_project_shares(project_id) + return jsonify({"shares": shares}) + + +@shares_bp.route("/api/projects//shares", methods=["POST"]) +@login_required +async def create_project_share(project_id: int): + uid = get_current_user_id() + data = await request.get_json() + share = await share_svc.share_project( + uid, project_id, + target_user_id=data.get("user_id"), + target_group_id=data.get("group_id"), + permission=data.get("permission", "viewer") + ) + if not share: + return jsonify({"error": "Forbidden or invalid permission"}), 403 + # Fire notification (fire-and-forget) + import asyncio + from fabledassistant.services.notifications import notify_project_shared + asyncio.create_task(notify_project_shared(project_id, share.permission, uid, + data.get("user_id"), data.get("group_id"))) + return jsonify(share.to_dict()), 201 + + +@shares_bp.route("/api/projects//shares/", methods=["PATCH"]) +@login_required +async def update_project_share(project_id: int, share_id: int): + uid = get_current_user_id() + data = await request.get_json() + share = await share_svc.update_project_share(uid, share_id, data.get("permission", "")) + if not share: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify(share.to_dict()) + + +@shares_bp.route("/api/projects//shares/", methods=["DELETE"]) +@login_required +async def remove_project_share(project_id: int, share_id: int): + uid = get_current_user_id() + removed = await share_svc.remove_project_share(uid, share_id) + if not removed: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify({"status": "ok"}) + + +@shares_bp.route("/api/notes//shares", methods=["GET"]) +@login_required +async def list_note_shares(note_id: int): + uid = get_current_user_id() + from fabledassistant.services.access import can_write_note + if not await can_write_note(uid, note_id): + return jsonify({"error": "Forbidden"}), 403 + shares = await share_svc.list_note_shares(note_id) + return jsonify({"shares": shares}) + + +@shares_bp.route("/api/notes//shares", methods=["POST"]) +@login_required +async def create_note_share(note_id: int): + uid = get_current_user_id() + data = await request.get_json() + share = await share_svc.share_note(uid, note_id, + target_user_id=data.get("user_id"), + target_group_id=data.get("group_id"), + permission=data.get("permission", "viewer")) + if not share: + return jsonify({"error": "Forbidden or invalid permission"}), 403 + import asyncio + from fabledassistant.services.notifications import notify_note_shared + asyncio.create_task(notify_note_shared(note_id, share.permission, uid, + data.get("user_id"), data.get("group_id"))) + return jsonify(share.to_dict()), 201 + + +@shares_bp.route("/api/notes//shares/", methods=["PATCH"]) +@login_required +async def update_note_share(note_id: int, share_id: int): + uid = get_current_user_id() + data = await request.get_json() + share = await share_svc.update_note_share(uid, share_id, data.get("permission", "")) + if not share: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify(share.to_dict()) + + +@shares_bp.route("/api/notes//shares/", methods=["DELETE"]) +@login_required +async def remove_note_share(note_id: int, share_id: int): + uid = get_current_user_id() + removed = await share_svc.remove_note_share(uid, share_id) + if not removed: + return jsonify({"error": "Not found or forbidden"}), 404 + return jsonify({"status": "ok"}) + + +@shares_bp.route("/api/shared-with-me", methods=["GET"]) +@login_required +async def shared_with_me(): + uid = get_current_user_id() + data = await share_svc.list_shared_with_me(uid) + return jsonify(data) +``` + +- [ ] **Step 3: Implement `routes/users.py`** + +```python +from quart import Blueprint, jsonify, request +from sqlalchemy import select, or_ +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.models import async_session +from fabledassistant.models.user import User + +users_bp = Blueprint("users", __name__, url_prefix="/api/users") + + +@users_bp.route("/search", methods=["GET"]) +@login_required +async def search_users(): + uid = get_current_user_id() + q = (request.args.get("q") or "").strip() + if len(q) < 2: + return jsonify({"users": []}) + async with async_session() as session: + like = f"{q}%" + users = (await session.execute( + select(User).where( + User.id != uid, + or_(User.username.ilike(like), User.email.ilike(like)) + ).limit(10) + )).scalars().all() + return jsonify({"users": [{"id": u.id, "username": u.username, "email": u.email} for u in users]}) +``` + +- [ ] **Step 4: Register blueprints in `app.py`** + +```python +from fabledassistant.routes.shares import shares_bp +from fabledassistant.routes.users import users_bp +# in create_app(): +app.register_blueprint(shares_bp) +app.register_blueprint(users_bp) +``` + +- [ ] **Step 5: Typecheck and commit** + +```bash +docker compose exec app python -m py_compile \ + src/fabledassistant/services/sharing.py \ + src/fabledassistant/routes/shares.py \ + src/fabledassistant/routes/users.py +git add src/fabledassistant/services/sharing.py src/fabledassistant/routes/shares.py src/fabledassistant/routes/users.py src/fabledassistant/app.py +git commit -m "feat: sharing service, share routes, user search endpoint" +``` + +--- + +## Chunk 4: Notifications + +### Task 6: Notifications service + routes + +**Files:** +- Create: `src/fabledassistant/services/notifications.py` +- Create: `src/fabledassistant/routes/notifications.py` +- Modify: `src/fabledassistant/app.py` + +- [ ] **Step 1: Implement `services/notifications.py`** + +```python +import logging +import asyncio +from datetime import datetime, timezone +from sqlalchemy import select, func, update +from fabledassistant.models import async_session +from fabledassistant.models.notification import Notification +from fabledassistant.models.user import User +from fabledassistant.services.push import send_push_notification + +logger = logging.getLogger(__name__) + + +async def create_notification(user_id: int, notif_type: str, payload: dict) -> Notification: + async with async_session() as session: + n = Notification(user_id=user_id, type=notif_type, payload=payload) + session.add(n) + await session.commit() + await session.refresh(n) + return n + + +async def _fire_push(user_id: int, title: str, body: str, url: str) -> None: + try: + await send_push_notification(user_id, title, body, url=url) + except Exception: + logger.exception("Push notification failed for user %d", user_id) + + +async def _fire_email(user_id: int, subject: str, body: str) -> None: + try: + from fabledassistant.services.email import is_smtp_configured, send_notification_email + if not await is_smtp_configured(): + return + async with async_session() as session: + user = await session.get(User, user_id) + if user and user.email: + await send_notification_email(user.email, subject, body) + except Exception: + logger.exception("Email notification failed for user %d", user_id) + + +async def _resolve_group_members(group_id: int) -> list[int]: + from fabledassistant.models.group import GroupMembership + async with async_session() as session: + rows = (await session.execute( + select(GroupMembership.user_id).where(GroupMembership.group_id == group_id) + )).scalars().all() + return list(rows) + + +async def notify_project_shared(project_id: int, permission: str, invited_by_user_id: int, + target_user_id: int | None, target_group_id: int | None) -> None: + from fabledassistant.models.project import Project + async with async_session() as session: + project = await session.get(Project, project_id) + inviter = await session.get(User, invited_by_user_id) + if not project or not inviter: + return + project_title = project.title + inviter_name = inviter.username + + recipients: list[int] = [] + if target_user_id: + recipients = [target_user_id] + elif target_group_id: + recipients = await _resolve_group_members(target_group_id) + recipients = [r for r in recipients if r != invited_by_user_id] + + message = f"{inviter_name} shared \"{project_title}\" with you as {permission}" + url = f"/projects/{project_id}" + + for uid in recipients: + await create_notification(uid, "project_shared", { + "project_id": project_id, + "project_title": project_title, + "permission": permission, + "invited_by": inviter_name, + "url": url, + }) + asyncio.create_task(_fire_push(uid, "Project shared with you", message, url)) + asyncio.create_task(_fire_email(uid, + f"[Fabled] {inviter_name} shared a project with you", + f"{message}\n\nView it at: {{base_url}}{url}" + )) + + +async def notify_note_shared(note_id: int, permission: str, invited_by_user_id: int, + target_user_id: int | None, target_group_id: int | None) -> None: + from fabledassistant.models.note import Note + async with async_session() as session: + note = await session.get(Note, note_id) + inviter = await session.get(User, invited_by_user_id) + if not note or not inviter: + return + note_title = note.title + inviter_name = inviter.username + is_task = note.is_task + + recipients: list[int] = [] + if target_user_id: + recipients = [target_user_id] + elif target_group_id: + recipients = await _resolve_group_members(target_group_id) + recipients = [r for r in recipients if r != invited_by_user_id] + + route = "tasks" if is_task else "notes" + url = f"/{route}/{note_id}" + message = f"{inviter_name} shared \"{note_title}\" with you as {permission}" + + for uid in recipients: + await create_notification(uid, "note_shared", { + "note_id": note_id, + "note_title": note_title, + "permission": permission, + "invited_by": inviter_name, + "url": url, + }) + asyncio.create_task(_fire_push(uid, "Note shared with you", message, url)) + asyncio.create_task(_fire_email(uid, + f"[Fabled] {inviter_name} shared a note with you", + f"{message}\n\nView it at: {{base_url}}{url}" + )) + + +async def notify_group_added(group_id: int, role: str, invited_by_user_id: int, + target_user_id: int) -> None: + from fabledassistant.models.group import Group + async with async_session() as session: + group = await session.get(Group, group_id) + inviter = await session.get(User, invited_by_user_id) + if not group or not inviter: + return + group_name = group.name + inviter_name = inviter.username + + url = f"/groups/{group_id}" + message = f"{inviter_name} added you to group \"{group_name}\" as {role}" + await create_notification(target_user_id, "group_added", { + "group_id": group_id, + "group_name": group_name, + "role": role, + "invited_by": inviter_name, + "url": url, + }) + asyncio.create_task(_fire_push(target_user_id, "Added to group", message, url)) + asyncio.create_task(_fire_email(target_user_id, + f"[Fabled] You've been added to a group", + f"{message}" + )) + + +async def list_notifications(user_id: int, unread_only: bool = True) -> list[dict]: + async with async_session() as session: + q = select(Notification).where(Notification.user_id == user_id) + if unread_only: + q = q.where(Notification.read_at.is_(None)) + q = q.order_by(Notification.created_at.desc()).limit(50) + rows = (await session.execute(q)).scalars().all() + return [n.to_dict() for n in rows] + + +async def unread_count(user_id: int) -> int: + async with async_session() as session: + result = await session.execute( + select(func.count()).where( + Notification.user_id == user_id, + Notification.read_at.is_(None) + ) + ) + return result.scalar() or 0 + + +async def mark_read(user_id: int, notification_id: int) -> bool: + async with async_session() as session: + n = (await session.execute( + select(Notification).where(Notification.id == notification_id, Notification.user_id == user_id) + )).scalar_one_or_none() + if not n: + return False + n.read_at = datetime.now(timezone.utc) + await session.commit() + return True + + +async def mark_all_read(user_id: int) -> int: + async with async_session() as session: + result = await session.execute( + update(Notification) + .where(Notification.user_id == user_id, Notification.read_at.is_(None)) + .values(read_at=datetime.now(timezone.utc)) + .returning(Notification.id) + ) + await session.commit() + return len(result.fetchall()) +``` + +- [ ] **Step 2: Add `send_notification_email` stub in `services/email.py`** + +Check if `send_notification_email` exists. If not, add: +```python +async def send_notification_email(to_email: str, subject: str, body: str) -> None: + """Send a simple plain-text notification email.""" + # Reuse existing send_email infrastructure + await send_email(to_email, subject, body) +``` + +- [ ] **Step 3: Update `routes/groups.py` to fire `notify_group_added`** + +In `add_member` route, after successful membership creation: +```python +import asyncio +from fabledassistant.services.notifications import notify_group_added +asyncio.create_task(notify_group_added(group_id, role, uid, target_uid)) +``` + +- [ ] **Step 4: Implement `routes/notifications.py`** + +```python +from quart import Blueprint, jsonify, request +from fabledassistant.auth import login_required, get_current_user_id +from fabledassistant.services import notifications as notif_svc + +notifications_bp = Blueprint("notifications", __name__, url_prefix="/api/notifications") + + +@notifications_bp.route("", methods=["GET"]) +@login_required +async def list_notifications(): + uid = get_current_user_id() + all_flag = request.args.get("all", "false").lower() == "true" + items = await notif_svc.list_notifications(uid, unread_only=not all_flag) + return jsonify({"notifications": items}) + + +@notifications_bp.route("/count", methods=["GET"]) +@login_required +async def get_count(): + uid = get_current_user_id() + count = await notif_svc.unread_count(uid) + return jsonify({"count": count}) + + +@notifications_bp.route("//read", methods=["POST"]) +@login_required +async def mark_read(notif_id: int): + uid = get_current_user_id() + ok = await notif_svc.mark_read(uid, notif_id) + if not ok: + return jsonify({"error": "Not found"}), 404 + return jsonify({"status": "ok"}) + + +@notifications_bp.route("/read-all", methods=["POST"]) +@login_required +async def mark_all_read(): + uid = get_current_user_id() + count = await notif_svc.mark_all_read(uid) + return jsonify({"status": "ok", "marked": count}) +``` + +- [ ] **Step 5: Register in `app.py`** + +```python +from fabledassistant.routes.notifications import notifications_bp +app.register_blueprint(notifications_bp) +``` + +- [ ] **Step 6: Rebuild and verify** + +```bash +docker compose up --build -d +curl -s -b cookies.txt http://localhost:5000/api/notifications/count +``` +Expected: `{"count": 0}` + +- [ ] **Step 7: Commit** + +```bash +git add src/fabledassistant/services/notifications.py src/fabledassistant/routes/notifications.py src/fabledassistant/routes/groups.py src/fabledassistant/app.py +git commit -m "feat: notification service and routes; group-add notification hook" +``` + +--- + +## Chunk 5: Modified services for shared access + +### Task 7: Extend project + note services with `_for_user` variants + +**Files:** +- Modify: `src/fabledassistant/services/projects.py` +- Modify: `src/fabledassistant/services/notes.py` +- Modify: `src/fabledassistant/routes/projects.py` +- Modify: `src/fabledassistant/routes/notes.py` +- Modify: `src/fabledassistant/routes/tasks.py` + +- [ ] **Step 1: Add `get_project_for_user` in `services/projects.py`** + +```python +async def get_project_for_user(accessing_user_id: int, project_id: int) -> tuple[Project, str] | None: + """Returns (project, permission) if user has any access, else None.""" + from fabledassistant.services.access import get_project_permission + perm = await get_project_permission(accessing_user_id, project_id) + if perm is None: + return None + async with async_session() as session: + project = await session.get(Project, project_id) + return (project, perm) if project else None + + +async def list_projects_for_user(user_id: int) -> list[dict]: + """Returns owned projects + shared projects, each with a 'permission' field.""" + owned = await list_projects(user_id) + owned_dicts = [{**p.to_dict() if hasattr(p, "to_dict") else {"id": p.id, "title": p.title}, + "permission": "owner"} for p in owned] + + from fabledassistant.models.share import ProjectShare + from fabledassistant.models.group import GroupMembership + async with async_session() as session: + user_group_ids = (await session.execute( + select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) + )).scalars().all() + + shared_shares = (await session.execute( + select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id) + )).scalars().all() + + group_shares = [] + if user_group_ids: + group_shares = (await session.execute( + select(ProjectShare).where(ProjectShare.shared_with_group_id.in_(user_group_ids)) + )).scalars().all() + + owned_ids = {p.id for p in owned} + seen: dict[int, str] = {} + for share in list(shared_shares) + list(group_shares): + if share.project_id in owned_ids: + continue + if share.project_id not in seen or PERMISSION_RANK[share.permission] > PERMISSION_RANK[seen[share.project_id]]: + seen[share.project_id] = share.permission + + shared_projects = [] + for pid, perm in seen.items(): + async with async_session() as session: + proj = await session.get(Project, pid) + if proj: + shared_projects.append({**{"id": proj.id, "title": proj.title, "description": proj.description, + "status": proj.status, "color": proj.color, + "updated_at": proj.updated_at.isoformat()}, + "permission": perm, "is_shared": True}) + + return owned_dicts + shared_projects +``` + +Note: Import `PERMISSION_RANK` from `services/access.py` to avoid duplication, or inline a local copy. + +- [ ] **Step 2: Add `get_note_for_user` in `services/notes.py`** + +```python +async def get_note_for_user(accessing_user_id: int, note_id: int) -> tuple[Note, str] | None: + """Returns (note, permission) or None if no access.""" + from fabledassistant.services.access import get_note_permission + perm = await get_note_permission(accessing_user_id, note_id) + if perm is None: + return None + async with async_session() as session: + note = await session.get(Note, note_id) + return (note, perm) if note else None +``` + +- [ ] **Step 3: Update `routes/projects.py` to use `_for_user` on read endpoints** + +`get_project_route` and `get_project_notes_route` should call `get_project_for_user` and pass permission to the response: +```python +@projects_bp.route("/", methods=["GET"]) +@login_required +async def get_project_route(project_id: int): + uid = get_current_user_id() + result = await get_project_for_user(uid, project_id) + if not result: + return jsonify({"error": "Not found"}), 404 + project, permission = result + # existing serialization logic + add "permission": permission to response + ... +``` + +`list_projects_route` → call `list_projects_for_user(uid)`. + +Mutation routes (PATCH, DELETE) continue to require owner or admin permission (checked via `can_admin_project`). + +- [ ] **Step 4: Update `routes/notes.py` and `routes/tasks.py`** + +Read-only routes (`GET /api/notes/:id`, `GET /api/tasks/:id`) call `get_note_for_user`. +Write routes verify `can_write_note`. Delete routes verify ownership (notes are deleted only by owner). + +- [ ] **Step 5: Typecheck** + +```bash +docker compose exec app python -m py_compile \ + src/fabledassistant/services/projects.py \ + src/fabledassistant/services/notes.py \ + src/fabledassistant/routes/projects.py \ + src/fabledassistant/routes/notes.py \ + src/fabledassistant/routes/tasks.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/fabledassistant/services/projects.py src/fabledassistant/services/notes.py \ + src/fabledassistant/routes/projects.py src/fabledassistant/routes/notes.py src/fabledassistant/routes/tasks.py +git commit -m "feat: extend project/note services with shared-access variants" +``` + +--- + +## Chunk 6: Frontend + +### Task 8: ShareDialog component + API client methods + +**Files:** +- Modify: `frontend/src/api/client.ts` +- Create: `frontend/src/components/ShareDialog.vue` + +- [ ] **Step 1: Add API methods to `client.ts`** + +```typescript +// Sharing +export const listProjectShares = (projectId: number) => + api.get(`/projects/${projectId}/shares`).then(r => r.data.shares) +export const createProjectShare = (projectId: number, body: object) => + api.post(`/projects/${projectId}/shares`, body).then(r => r.data) +export const updateProjectShare = (projectId: number, shareId: number, permission: string) => + api.patch(`/projects/${projectId}/shares/${shareId}`, { permission }).then(r => r.data) +export const deleteProjectShare = (projectId: number, shareId: number) => + api.delete(`/projects/${projectId}/shares/${shareId}`) + +export const listNoteShares = (noteId: number) => + api.get(`/notes/${noteId}/shares`).then(r => r.data.shares) +export const createNoteShare = (noteId: number, body: object) => + api.post(`/notes/${noteId}/shares`, body).then(r => r.data) +export const updateNoteShare = (noteId: number, shareId: number, permission: string) => + api.patch(`/notes/${noteId}/shares/${shareId}`, { permission }).then(r => r.data) +export const deleteNoteShare = (noteId: number, shareId: number) => + api.delete(`/notes/${noteId}/shares/${shareId}`) + +export const searchUsers = (q: string) => + api.get('/users/search', { params: { q } }).then(r => r.data.users) +export const listGroups = () => + api.get('/groups').then(r => r.data.groups) +export const sharedWithMe = () => + api.get('/shared-with-me').then(r => r.data) + +// Notifications +export const getNotifications = (all = false) => + api.get('/notifications', { params: all ? { all: 'true' } : {} }).then(r => r.data.notifications) +export const getNotificationCount = () => + api.get('/notifications/count').then(r => r.data.count as number) +export const markNotificationRead = (id: number) => + api.post(`/notifications/${id}/read`) +export const markAllNotificationsRead = () => + api.post('/notifications/read-all') +``` + +- [ ] **Step 2: Build `ShareDialog.vue`** + +The dialog is a modal overlay. Key structure: + +```vue + +``` + +Props: `resourceType: 'project' | 'note'`, `resourceId: number`, `resourceTitle: string` +Emits: `close` + +Use `searchUsers` with a 300ms debounce for the user search input. +Load groups on mount via `listGroups()`. +Load current shares on mount. + +- [ ] **Step 3: TypeScript typecheck** + +```bash +cd frontend && npx vue-tsc --noEmit +``` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/api/client.ts frontend/src/components/ShareDialog.vue +git commit -m "feat: ShareDialog component and sharing API client methods" +``` + +--- + +### Task 9: Notification bell + panel + +**Files:** +- Create: `frontend/src/components/NotificationBell.vue` +- Create: `frontend/src/components/NotificationsPanel.vue` +- Create: `frontend/src/stores/notifications.ts` +- Modify: `frontend/src/App.vue` + +- [ ] **Step 1: Create notifications Pinia store** + +```typescript +// frontend/src/stores/notifications.ts +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getNotificationCount, getNotifications, markAllNotificationsRead, markNotificationRead } from '@/api/client' + +export const useNotificationsStore = defineStore('notifications', () => { + const count = ref(0) + const items = ref([]) + + async function fetchCount() { + count.value = await getNotificationCount() + } + + async function fetchAll() { + items.value = await getNotifications(false) + count.value = items.value.length + } + + async function markRead(id: number) { + await markNotificationRead(id) + items.value = items.value.filter(n => n.id !== id) + count.value = Math.max(0, count.value - 1) + } + + async function markAll() { + await markAllNotificationsRead() + items.value = [] + count.value = 0 + } + + return { count, items, fetchCount, fetchAll, markRead, markAll } +}) +``` + +- [ ] **Step 2: Build `NotificationBell.vue`** + +Bell SVG icon with `.badge` absolutely positioned. Clicking opens/closes the panel. +Polls `fetchCount()` every 60s using `setInterval` in `onMounted`, cleared in `onUnmounted`. + +```vue + +``` + +- [ ] **Step 3: Build `NotificationsPanel.vue`** + +Positioned dropdown (absolute, right-aligned). Lists notifications with icon, message, relative time. "Mark all read" button. Click on notification navigates to `payload.url` and marks it read. + +- [ ] **Step 4: Add `NotificationBell` to `App.vue` nav** + +Place alongside existing nav controls (before the user menu or theme toggle). + +- [ ] **Step 5: TypeScript typecheck + commit** + +```bash +cd frontend && npx vue-tsc --noEmit +git add frontend/src/stores/notifications.ts frontend/src/components/NotificationBell.vue frontend/src/components/NotificationsPanel.vue frontend/src/App.vue +git commit -m "feat: notification bell, panel, and Pinia store" +``` + +--- + +### Task 10: Shared-with-me view + nav integration + +**Files:** +- Create: `frontend/src/views/SharedWithMeView.vue` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/App.vue` +- Modify: `frontend/src/views/HomeView.vue` + +- [ ] **Step 1: Build `SharedWithMeView.vue`** + +Route: `/shared` + +Two sections — Shared Projects, Shared Notes & Tasks: +- Project cards matching `ProjectCard` style; show owner username + permission badge +- Note rows with title, owner, project (if any), permission badge, link to note +- Empty state with friendly message when nothing is shared + +- [ ] **Step 2: Register route in `router/index.ts`** + +```typescript +{ path: '/shared', name: 'shared-with-me', + component: () => import('@/views/SharedWithMeView.vue') }, +``` + +- [ ] **Step 3: Add nav item to `App.vue`** + +People icon + "Shared" label, linking to `/shared`. + +- [ ] **Step 4: Add shared-with-me summary to `HomeView.vue`** + +Below active projects section: +``` +Shared with me +[ Project A - editor ] [ Note: Design doc - viewer ] → View all +``` +Max 3 items, "View all →" links to `/shared`. + +- [ ] **Step 5: Add Share button to `ProjectView.vue`, `NoteViewerView.vue`, `TaskViewerView.vue`** + +```vue + + +``` + +- [ ] **Step 6: TypeScript typecheck + commit** + +```bash +cd frontend && npx vue-tsc --noEmit +git add frontend/src/views/SharedWithMeView.vue frontend/src/router/index.ts frontend/src/App.vue frontend/src/views/HomeView.vue frontend/src/views/ProjectView.vue frontend/src/views/NoteViewerView.vue frontend/src/views/TaskViewerView.vue +git commit -m "feat: SharedWithMeView, nav integration, Share buttons on resource views" +``` + +--- + +### Task 11: Admin groups management in SettingsView + +**Files:** +- Modify: `frontend/src/views/SettingsView.vue` + +- [ ] **Step 1: Add API methods for admin group management to `client.ts`** + +```typescript +export const adminListGroups = () => api.get('/groups').then(r => r.data.groups) +export const adminCreateGroup = (name: string, description?: string) => + api.post('/groups', { name, description }).then(r => r.data) +export const adminDeleteGroup = (id: number) => api.delete(`/groups/${id}`) +export const getGroupMembers = (id: number) => + api.get(`/groups/${id}/members`).then(r => r.data.members) +export const addGroupMember = (groupId: number, userId: number, role: string) => + api.post(`/groups/${groupId}/members`, { user_id: userId, role }).then(r => r.data) +export const updateGroupMember = (groupId: number, userId: number, role: string) => + api.patch(`/groups/${groupId}/members/${userId}`, { role }).then(r => r.data) +export const removeGroupMember = (groupId: number, userId: number) => + api.delete(`/groups/${groupId}/members/${userId}`) +``` + +- [ ] **Step 2: Add "Groups" tab to Admin section in `SettingsView.vue`** + +In the Admin tab (already gated by `authStore.isAdmin`), add a "Groups" subsection: +- Group list with name, member count, delete button +- "Create group" form (name + optional description) +- Expand group → show member list with role, remove button; "Add member" form with user search + +The admin can manage all groups; group owners who are not admins manage via the normal `/groups` API but don't see this admin panel. + +- [ ] **Step 3: TypeScript typecheck + commit** + +```bash +cd frontend && npx vue-tsc --noEmit +git add frontend/src/views/SettingsView.vue frontend/src/api/client.ts +git commit -m "feat: admin groups management in SettingsView" +``` + +--- + +## Chunk 7: Verification + +### Task 12: End-to-end verification + +- [ ] **Step 1: Rebuild** + +```bash +docker compose up --build -d +``` + +- [ ] **Step 2: Multi-user flow** + +With two accounts (user A = owner, user B = collaborator): +1. User A creates a project → creates note inside it +2. User A opens ProjectView → "Share" button present → click +3. ShareDialog opens → search for user B → select "editor" → Add +4. User B receives notification (bell badge appears) +5. User B opens notification → navigates to project → can view project + notes +6. User B edits a note → save succeeds +7. User B tries to delete project → 403 (only viewer+ can't delete) +8. User A opens `/shared` as user B → project appears with "editor" badge + +- [ ] **Step 3: Group flow** + +1. Admin creates group via Settings → Groups +2. Admin adds user B to group +3. User B receives group notification +4. User A shares project with group +5. User B sees project in "Shared with me" + +- [ ] **Step 4: TypeScript final check** + +```bash +cd frontend && npx vue-tsc --noEmit +``` +Expected: No errors. + +- [ ] **Step 5: Final commit** + +```bash +git add -A +git commit -m "chore: sharing feature complete — all chunks verified" +``` diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 026ec3f..8fb6d7d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,28 +8,44 @@ "name": "fabledassistant-frontend", "version": "0.1.0", "dependencies": { - "@tiptap/extension-link": "^2.11.0", - "@tiptap/extension-placeholder": "^2.11.0", - "@tiptap/extension-task-item": "^2.27.2", - "@tiptap/extension-task-list": "^2.27.2", - "@tiptap/pm": "^2.11.0", - "@tiptap/starter-kit": "^2.11.0", - "@tiptap/suggestion": "^2.11.0", - "@tiptap/vue-3": "^2.11.0", + "@tiptap/core": "^3.0.0", + "@tiptap/extension-link": "^3.0.0", + "@tiptap/extension-list": "^3.0.0", + "@tiptap/extensions": "^3.0.0", + "@tiptap/pm": "^3.0.0", + "@tiptap/starter-kit": "^3.0.0", + "@tiptap/suggestion": "^3.0.0", + "@tiptap/vue-3": "^3.0.0", "d3": "^7", "dompurify": "^3.1.0", - "marked": "^15.0.0", - "pinia": "^2.2.0", - "vue": "^3.5.0", - "vue-router": "^4.4.0" + "marked": "^17.0.0", + "pinia": "^3.0.0", + "vue": "3.5.30", + "vue-router": "^5.0.0" }, "devDependencies": { "@types/d3": "^7", "@types/dompurify": "^3.0.0", - "@vitejs/plugin-vue": "^5.1.0", - "typescript": "~5.6.0", - "vite": "^6.0.0", - "vue-tsc": "^2.1.0" + "@vitejs/plugin-vue": "^6.0.0", + "typescript": "~5.9.0", + "vite": "^7.0.0", + "vue-tsc": "^3.0.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { @@ -79,9 +95,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -96,9 +112,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -113,9 +129,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -130,9 +146,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -147,9 +163,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -164,9 +180,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -181,9 +197,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -198,9 +214,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -215,9 +231,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -232,9 +248,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -249,9 +265,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -266,9 +282,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -283,9 +299,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -300,9 +316,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -317,9 +333,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -334,9 +350,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -351,9 +367,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -368,9 +384,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -385,9 +401,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -402,9 +418,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -419,9 +435,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -436,9 +452,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -453,9 +469,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -470,9 +486,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -487,9 +503,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -504,9 +520,9 @@ } }, "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==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -520,20 +536,77 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "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==", "license": "MIT" }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@remirror/core-constants": { @@ -542,6 +615,13 @@ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", "license": "MIT" }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -932,230 +1012,214 @@ ] }, "node_modules/@tiptap/core": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", - "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.1.tgz", + "integrity": "sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^2.7.0" + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-blockquote": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", - "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.1.tgz", + "integrity": "sha512-WzNXk/63PQI2fav4Ta6P0GmYRyu8Gap1pV3VUqaVK829iJ6Zt1T21xayATHEHWMK27VT1GLPJkx9Ycr2jfDyQw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-bold": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", - "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.1.tgz", + "integrity": "sha512-fz++Qv6Rk/Hov0IYG/r7TJ1Y4zWkuGONe0UN5g0KY32NIMg3HeOHicbi4xsNWTm9uAOl3eawWDkezEMrleObMw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", - "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.1.tgz", + "integrity": "sha512-XaPvO6aCoWdFnCBus0s88lnj17NR/OopV79i8Qhgz3WMR0vrsL5zsd45l0lZuu9pSvm5VW47SoxakkJiZC1suw==", "license": "MIT", + "optional": true, "dependencies": { - "tippy.js": "^6.3.7" + "@floating-ui/dom": "^1.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", - "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.1.tgz", + "integrity": "sha512-mbrlvOZo5OF3vLhp+3fk9KuL/6J/wsN0QxF6ZFRAHzQ9NkJdtdfARcBeBnkWXGN8inB6YxbTGY1/E4lmBkOpOw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/extension-list": "^3.20.1" } }, "node_modules/@tiptap/extension-code": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", - "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.1.tgz", + "integrity": "sha512-509DHINIA/Gg+eTG7TEkfsS8RUiPLH5xZNyLRT0A1oaoaJmECKfrV6aAm05IdfTyqDqz6LW5pbnX6DdUC4keug==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-code-block": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", - "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.1.tgz", + "integrity": "sha512-vKejwBq+Nlj4Ybd3qOyDxIQKzYymdNH+8eXkKwGShk2nfLJIxq69DCyGvmuHgipIO1qcYPJ149UNpGN+YGcdmA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-document": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", - "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.1.tgz", + "integrity": "sha512-9vrqdGmRV7bQCSY3NLgu7UhIwgOCDp4sKqMNsoNRX0aZ021QQMTvBQDPkiRkCf7MNsnWrNNnr52PVnULEn3vFQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", - "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.1.tgz", + "integrity": "sha512-K18L9FX4znn+ViPSIbTLOGcIaXMx/gLNwAPE8wPLwswbHhQqdiY1zzdBw6drgOc1Hicvebo2dIoUlSXOZsOEcw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/extensions": "^3.20.1" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", - "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.1.tgz", + "integrity": "sha512-BeDC6nfOesIMn5pFuUnkEjOxGv80sOJ8uk1mdt9/3Fkvra8cB9NIYYCVtd6PU8oQFmJ8vFqPrRkUWrG5tbqnOg==", "license": "MIT", - "dependencies": { - "tippy.js": "^6.3.7" - }, + "optional": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", - "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.1.tgz", + "integrity": "sha512-kZOtttV6Ai8VUAgEng3h4WKFbtdSNJ6ps7r0cRPY+FctWhVmgNb/JJwwyC+vSilR7nRENAhrA/Cv/RxVlvLw+g==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/extensions": "^3.20.1" } }, "node_modules/@tiptap/extension-hard-break": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", - "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.1.tgz", + "integrity": "sha512-9sKpmg/IIdlLXimYWUZ3PplIRcehv4Oc7V1miTqlnAthMzjMqigDkjjgte4JZV67RdnDJTQkRw8TklCAU28Emg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-heading": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", - "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.1.tgz", + "integrity": "sha512-unudyfQP6FxnyWinxvPqe/51DG91J6AaJm666RnAubgYMCgym+33kBftx4j4A6qf+ddWYbD00thMNKOnVLjAEQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" - } - }, - "node_modules/@tiptap/extension-history": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", - "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", - "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.1.tgz", + "integrity": "sha512-rjFKFXNntdl0jay8oIGFvvykHlpyQTLmrH3Ag2fj3i8yh6MVvqhtaDomYQbw5sxECd5hBkL+T4n2d2DRuVw/QQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-italic": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", - "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.1.tgz", + "integrity": "sha512-ZYRX13Kt8tR8JOzSXirH3pRpi8x30o7LHxZY58uXBdUvr3tFzOkh03qbN523+diidSVeHP/aMd/+IrplHRkQug==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-link": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", - "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.1.tgz", + "integrity": "sha512-oYTTIgsQMqpkSnJAuAc+UtIKMuI4lv9e1y4LfI1iYm6NkEUHhONppU59smhxHLzb3Ww7YpDffbp5IgDTAiJztA==", "license": "MIT", "dependencies": { "linkifyjs": "^4.3.2" @@ -1165,133 +1229,133 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.1.tgz", + "integrity": "sha512-euBRAn0mkV7R2VEE+AuOt3R0j9RHEMFXamPFmtvTo8IInxDClusrm6mJoDjS8gCGAXsQCRiAe1SCQBPgGbOOwg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", - "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.1.tgz", + "integrity": "sha512-tzgnyTW82lYJkUnadYbatwkI9dLz/OWRSWuFpQPRje/ItmFMWuQ9c9NDD8qLbXPdEYnvrgSAA+ipCD/1G0qA0Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/extension-list": "^3.20.1" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.1.tgz", + "integrity": "sha512-Dr0xsQKx0XPOgDg7xqoWwfv7FFwZ3WeF3eOjqh3rDXlNHMj1v+UW5cj1HLphrsAZHTrVTn2C+VWPJkMZrSbpvQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.1" } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", - "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.1.tgz", + "integrity": "sha512-Y+3Ad7OwAdagqdYwCnbqf7/to5ypD4NnUNHA0TXRCs7cAHRA8AdgPoIcGFpaaSpV86oosNU3yfeJouYeroffog==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/extension-list": "^3.20.1" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", - "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.1.tgz", + "integrity": "sha512-QFrAtXNyv7JSnomMQc1nx5AnG9mMznfbYJAbdOQYVdbLtAzTfiTuNPNbQrufy5ZGtGaHxDCoaygu2QEfzaKG+Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" - } - }, - "node_modules/@tiptap/extension-placeholder": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz", - "integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-strike": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", - "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.1.tgz", + "integrity": "sha512-EYgyma10lpsY+rwbVQL9u+gA7hBlKLSMFH7Zgd37FSxukOjr+HE8iKPQQ+SwbGejyDsPlLT8Z5Jnuxo5Ng90Pg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" - } - }, - "node_modules/@tiptap/extension-task-item": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.27.2.tgz", - "integrity": "sha512-ZBSqj/dygB/Rp5K9qOxRVwASTZCmKVoTq8C59KvMgD/aFjJxhq/w2dZaWkCUEXEep+NmvJqo0kfeAEMY5UDnGg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" - } - }, - "node_modules/@tiptap/extension-task-list": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.27.2.tgz", - "integrity": "sha512-5nupAewdzZ9F3599oAcaK0WkDH04wdACAVBPM4zG7InlIpkbho3txB7zWmm64OxfhCMIMGKiXY1q0bw9i0QBGQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, "node_modules/@tiptap/extension-text": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", - "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.1.tgz", + "integrity": "sha512-7PlIbYW8UenV6NPOXHmv8IcmPGlGx6HFq66RmkJAOJRPXPkTLAiX0N8rQtzUJ6jDEHqoJpaHFEHJw0xzW1yF+A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" } }, - "node_modules/@tiptap/extension-text-style": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", - "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "node_modules/@tiptap/extension-underline": { + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.1.tgz", + "integrity": "sha512-fmHvDKzwCgnZUwRreq8tYkb1YyEwgzZ6QQkAQ0CsCRtvRMqzerr3Duz0Als4i8voZTuGDEL3VR6nAJbLAb/wPg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "^3.20.1" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.1.tgz", + "integrity": "sha512-JRc/v+OBH0qLTdvQ7HvHWTxGJH73QOf1MC0R8NhOX2QnAbg2mPFv1h+FjGa2gfLGuCXBdWQomjekWkUKbC4e5A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/pm": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", - "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.1.tgz", + "integrity": "sha512-6kCiGLvpES4AxcEuOhb7HR7/xIeJWMjZlb6J7e8zpiIh5BoQc7NoRdctsnmFEjZvC19bIasccshHQ7H2zchWqw==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", @@ -1304,14 +1368,14 @@ "prosemirror-keymap": "^1.2.2", "prosemirror-markdown": "^1.13.1", "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.23.0", + "prosemirror-model": "^1.24.1", "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.4.1", + "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.37.0" + "prosemirror-view": "^1.38.1" }, "funding": { "type": "github", @@ -1319,32 +1383,35 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", - "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.1.tgz", + "integrity": "sha512-opqWxL/4OTEiqmVC0wsU4o3JhAf6LycJ2G/gRIZVAIFLljI9uHfpPMTFGxZ5w9IVVJaP5PJysfwW/635kKqkrw==", "license": "MIT", "dependencies": { - "@tiptap/core": "^2.27.2", - "@tiptap/extension-blockquote": "^2.27.2", - "@tiptap/extension-bold": "^2.27.2", - "@tiptap/extension-bullet-list": "^2.27.2", - "@tiptap/extension-code": "^2.27.2", - "@tiptap/extension-code-block": "^2.27.2", - "@tiptap/extension-document": "^2.27.2", - "@tiptap/extension-dropcursor": "^2.27.2", - "@tiptap/extension-gapcursor": "^2.27.2", - "@tiptap/extension-hard-break": "^2.27.2", - "@tiptap/extension-heading": "^2.27.2", - "@tiptap/extension-history": "^2.27.2", - "@tiptap/extension-horizontal-rule": "^2.27.2", - "@tiptap/extension-italic": "^2.27.2", - "@tiptap/extension-list-item": "^2.27.2", - "@tiptap/extension-ordered-list": "^2.27.2", - "@tiptap/extension-paragraph": "^2.27.2", - "@tiptap/extension-strike": "^2.27.2", - "@tiptap/extension-text": "^2.27.2", - "@tiptap/extension-text-style": "^2.27.2", - "@tiptap/pm": "^2.27.2" + "@tiptap/core": "^3.20.1", + "@tiptap/extension-blockquote": "^3.20.1", + "@tiptap/extension-bold": "^3.20.1", + "@tiptap/extension-bullet-list": "^3.20.1", + "@tiptap/extension-code": "^3.20.1", + "@tiptap/extension-code-block": "^3.20.1", + "@tiptap/extension-document": "^3.20.1", + "@tiptap/extension-dropcursor": "^3.20.1", + "@tiptap/extension-gapcursor": "^3.20.1", + "@tiptap/extension-hard-break": "^3.20.1", + "@tiptap/extension-heading": "^3.20.1", + "@tiptap/extension-horizontal-rule": "^3.20.1", + "@tiptap/extension-italic": "^3.20.1", + "@tiptap/extension-link": "^3.20.1", + "@tiptap/extension-list": "^3.20.1", + "@tiptap/extension-list-item": "^3.20.1", + "@tiptap/extension-list-keymap": "^3.20.1", + "@tiptap/extension-ordered-list": "^3.20.1", + "@tiptap/extension-paragraph": "^3.20.1", + "@tiptap/extension-strike": "^3.20.1", + "@tiptap/extension-text": "^3.20.1", + "@tiptap/extension-underline": "^3.20.1", + "@tiptap/extensions": "^3.20.1", + "@tiptap/pm": "^3.20.1" }, "funding": { "type": "github", @@ -1352,35 +1419,36 @@ } }, "node_modules/@tiptap/suggestion": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.27.2.tgz", - "integrity": "sha512-dQyvCIg0hcAVeh4fCIVCxogvbp+bF+GpbUb8sNlgnGrmHXnapGxzkvrlHnvneXZxLk/j7CxmBPKJNnm4Pbx4zw==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.20.1.tgz", + "integrity": "sha512-ng7olbzgZhWvPJVJygNQK5153CjquR2eJXpkLq7bRjHlahvt4TH4tGFYvGdYZcXuzbe2g9RoqT7NaPGL9CUq9w==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1" } }, "node_modules/@tiptap/vue-3": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-2.27.2.tgz", - "integrity": "sha512-NahnVLTAQsbLaNU9nGLdGCr88nAeQZJTejjBVQc3EzMdijmE46R44Rosj6O/pj3e7eLj1/gYvc+U/hIVbxMpoQ==", + "version": "3.20.1", + "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.20.1.tgz", + "integrity": "sha512-06IsNzIkAC0HPHNSdXf4AgtExnr02BHBLXmUiNrjjhgHdxXDKIB0Yq3hEWEfbWNPr3lr7jK6EaFu2IKFMhoUtQ==", "license": "MIT", - "dependencies": { - "@tiptap/extension-bubble-menu": "^2.27.2", - "@tiptap/extension-floating-menu": "^2.27.2" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.20.1", + "@tiptap/extension-floating-menu": "^3.20.1" + }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0", + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.20.1", + "@tiptap/pm": "^3.20.1", "vue": "^3.0.0" } }, @@ -1722,194 +1790,243 @@ "license": "MIT" }, "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==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", "dev": true, "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.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==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.15" + "@volar/source-map": "2.4.28" } }, "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==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", - "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.15", + "@volar/language-core": "2.4.28", "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==", + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.27", - "entities": "^7.0.0", + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" } }, "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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", "license": "MIT", "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", + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", "license": "MIT", "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, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" } }, "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==", - "license": "MIT" + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz", + "integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.15", + "@volar/language-core": "2.4.28", "@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", + "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" } }, "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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.27" + "@vue/shared": "3.5.30" } }, "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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" } }, "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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.27", - "@vue/runtime-core": "3.5.27", - "@vue/shared": "3.5.27", + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", "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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.27", - "@vue/shared": "3.5.27" + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" }, "peerDependencies": { - "vue": "3.5.27" + "vue": "3.5.30" } }, "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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", "license": "MIT" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "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==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", "dev": true, "license": "MIT" }, @@ -1919,21 +2036,60 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "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, - "license": "MIT" - }, - "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, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/commander": { @@ -1945,6 +2101,27 @@ "node": ">= 10" } }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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", @@ -2358,13 +2535,6 @@ "node": ">=12" } }, - "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, - "license": "MIT" - }, "node_modules/delaunator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", @@ -2399,9 +2569,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2412,32 +2582,32 @@ "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" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escape-string-regexp": { @@ -2458,11 +2628,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, "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, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2491,15 +2666,11 @@ "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, - "license": "MIT", - "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==", + "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.6.3", @@ -2522,6 +2693,42 @@ "node": ">=12" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2537,6 +2744,23 @@ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "license": "MIT" }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2546,6 +2770,21 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -2576,15 +2815,15 @@ } }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/mdurl": { @@ -2593,27 +2832,45 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", + "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==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", + "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, "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, "license": "MIT" }, "node_modules/nanoid": { @@ -2647,6 +2904,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2657,7 +2926,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2667,20 +2935,19 @@ } }, "node_modules/pinia": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", - "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", "dependencies": { - "@vue/devtools-api": "^6.6.3", - "vue-demi": "^0.14.10" + "@vue/devtools-api": "^7.7.7" }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "typescript": ">=4.4.4", - "vue": "^2.7.0 || ^3.5.11" + "typescript": ">=4.5.0", + "vue": "^3.5.11" }, "peerDependenciesMeta": { "typescript": { @@ -2688,10 +2955,21 @@ } } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -2920,6 +3198,41 @@ "node": ">=6" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -2989,6 +3302,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2998,11 +3317,31 @@ "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "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, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3015,20 +3354,11 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tippy.js": { - "version": "6.3.7", - "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", - "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", - "license": "MIT", - "dependencies": { - "@popperjs/core": "^2.9.0" - } - }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "devOptional": true, + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3044,25 +3374,61 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "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==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "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" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3071,14 +3437,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -3127,16 +3493,16 @@ "license": "MIT" }, "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==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", "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" + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" @@ -3147,56 +3513,93 @@ } } }, - "node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "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==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz", + "integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==", "license": "MIT", "dependencies": { - "@vue/devtools-api": "^6.6.4" + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } } }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.7.tgz", + "integrity": "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.7" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz", + "integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.7", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz", + "integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, "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==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz", + "integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==", "dev": true, "license": "MIT", "dependencies": { - "@volar/typescript": "2.4.15", - "@vue/language-core": "2.2.12" + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.5" }, "bin": { "vue-tsc": "bin/vue-tsc.js" @@ -3210,6 +3613,27 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/frontend/package.json b/frontend/package.json index edfbf05..6dd33ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,27 +9,27 @@ "preview": "vite preview" }, "dependencies": { - "@tiptap/extension-link": "^2.11.0", - "@tiptap/extension-placeholder": "^2.11.0", - "@tiptap/extension-task-item": "^2.27.2", - "@tiptap/extension-task-list": "^2.27.2", - "@tiptap/pm": "^2.11.0", - "@tiptap/starter-kit": "^2.11.0", - "@tiptap/suggestion": "^2.11.0", - "@tiptap/vue-3": "^2.11.0", + "@tiptap/core": "^3.0.0", + "@tiptap/extension-link": "^3.0.0", + "@tiptap/extension-list": "^3.0.0", + "@tiptap/extensions": "^3.0.0", + "@tiptap/pm": "^3.0.0", + "@tiptap/starter-kit": "^3.0.0", + "@tiptap/suggestion": "^3.0.0", + "@tiptap/vue-3": "^3.0.0", "d3": "^7", "dompurify": "^3.1.0", - "marked": "^15.0.0", - "pinia": "^2.2.0", - "vue": "^3.5.0", - "vue-router": "^4.4.0" + "marked": "^17.0.0", + "pinia": "^3.0.0", + "vue": "3.5.30", + "vue-router": "^5.0.0" }, "devDependencies": { "@types/d3": "^7", "@types/dompurify": "^3.0.0", - "@vitejs/plugin-vue": "^5.1.0", - "typescript": "~5.6.0", - "vite": "^6.0.0", - "vue-tsc": "^2.1.0" + "@vitejs/plugin-vue": "^6.0.0", + "typescript": "~5.9.0", + "vite": "^7.0.0", + "vue-tsc": "^3.0.0" } } diff --git a/frontend/public/sw.js b/frontend/public/sw.js index cd54d9c..246a13b 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -2,12 +2,22 @@ self.addEventListener('push', event => { if (!event.data) return; const data = event.data.json(); + const notifUrl = data.url || '/'; + event.waitUntil( - self.registration.showNotification(data.title || 'Fabled Assistant', { - body: data.body || '', - icon: '/favicon.ico', - badge: '/favicon.ico', - data: { url: data.url || '/' }, + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { + // Suppress notification if the user already has the relevant page focused + for (const client of clientList) { + if (client.visibilityState === 'visible' && client.url.includes(notifUrl)) { + return; + } + } + return self.registration.showNotification(data.title || 'Fabled Assistant', { + body: data.body || '', + icon: '/favicon.ico', + badge: '/favicon.ico', + data: { url: notifUrl }, + }); }) ); }); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ca39dc3..00db060 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@ @@ -577,4 +629,46 @@ async function applyTag(tag: string) { } .tag-pill:disabled:not(.applied) { opacity: 0.6; cursor: wait; } .tag-check { font-size: 0.65rem; } + +/* Duplicate confirmation */ +.tool-call-card.requires-confirm { + border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 60%, transparent); +} +.confirm-created { + color: var(--color-success, #2ecc71); + font-size: 0.78rem; + font-weight: 600; +} +.confirm-denied { + color: var(--color-text-muted); + font-size: 0.78rem; + font-style: italic; +} +.btn-confirm-duplicate { + padding: 0.15rem 0.5rem; + background: var(--color-primary); + color: #fff; + border: none; + border-radius: var(--radius-sm, 6px); + cursor: pointer; + font-size: 0.72rem; + font-family: inherit; + white-space: nowrap; +} +.btn-confirm-duplicate:hover { opacity: 0.9; } +.btn-deny-duplicate { + padding: 0.15rem 0.5rem; + background: none; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 6px); + cursor: pointer; + font-size: 0.72rem; + font-family: inherit; + white-space: nowrap; +} +.btn-deny-duplicate:hover { + color: var(--color-danger, #e74c3c); + border-color: var(--color-danger, #e74c3c); +} diff --git a/frontend/src/components/VersionHistorySection.vue b/frontend/src/components/VersionHistorySection.vue new file mode 100644 index 0000000..59c6926 --- /dev/null +++ b/frontend/src/components/VersionHistorySection.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/frontend/src/components/WorkspaceNoteEditor.vue b/frontend/src/components/WorkspaceNoteEditor.vue index c90bac5..4c02c2f 100644 --- a/frontend/src/components/WorkspaceNoteEditor.vue +++ b/frontend/src/components/WorkspaceNoteEditor.vue @@ -28,23 +28,18 @@ interface NoteItem { const toast = useToastStore(); const notesStore = useNotesStore(); -// View state: "list" or "editor" -const view = ref<"list" | "editor">("list"); - // List state const projectNotes = ref([]); const listLoading = ref(false); -const deletingId = ref(null); // confirming delete for this id -const pendingDelete = ref(null); // mid-deletion +const deletingId = ref(null); +const pendingDelete = ref(null); const searchQuery = ref(""); const filteredNotes = computed(() => { const q = searchQuery.value.trim().toLowerCase(); if (!q) return projectNotes.value; return projectNotes.value.filter( - (n) => - n.title.toLowerCase().includes(q) || - n.tags?.some((t) => t.toLowerCase().includes(q)) + (n) => n.title.toLowerCase().includes(q) || n.tags?.some((t) => t.toLowerCase().includes(q)) ); }); @@ -72,6 +67,8 @@ const newNoteTitle = ref(""); const creatingNote = ref(false); const newNoteTitleRef = ref(null); +function _noteKey(pid: number) { return `workspace_note_${pid}`; } + async function startNewNote() { showNewNoteInput.value = true; newNoteTitle.value = ""; @@ -104,7 +101,7 @@ async function createNote() { } } -// TipTap editor ref (for MarkdownToolbar) +// TipTap editor ref const titleRef = ref(null); const editorRef = ref | null>(null); const tiptapEditor = computed(() => @@ -184,7 +181,7 @@ async function openNote(id: number) { dirty.value = false; tagSuggestions.dismissTagSuggestions(); linkSuggestions.value = []; - view.value = "editor"; + localStorage.setItem(_noteKey(props.projectId), String(id)); fetchLinkSuggestions(); emit("note-loaded", note.id); } catch { @@ -202,7 +199,6 @@ async function saveNote() { tags: noteTags.value, }); dirty.value = false; - // Refresh updated_at in the list const idx = projectNotes.value.findIndex((n) => n.id === editingId.value); if (idx !== -1) { projectNotes.value[idx] = { @@ -211,7 +207,6 @@ async function saveNote() { tags: noteTags.value, updated_at: new Date().toISOString(), }; - // Re-sort by updated_at projectNotes.value.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() ); @@ -223,16 +218,9 @@ async function saveNote() { } } -function backToList() { - view.value = "list"; - deletingId.value = null; - searchQuery.value = ""; -} - function requestDelete(id: number, e: Event) { e.stopPropagation(); if (deletingId.value === id) { - // Second click — confirm confirmDelete(id); } else { deletingId.value = id; @@ -253,7 +241,7 @@ async function confirmDelete(id: number) { editingId.value = null; noteTitle.value = ""; noteBody.value = ""; - view.value = "list"; + localStorage.removeItem(_noteKey(props.projectId)); } } catch { toast.show("Failed to delete note", "error"); @@ -292,93 +280,27 @@ watch( ); useAutoSave(dirty, saving, saveNote, 60_000); -onMounted(loadProjectNotes); + +onMounted(async () => { + await loadProjectNotes(); + const stored = localStorage.getItem(_noteKey(props.projectId)); + if (stored) { + const id = Number(stored); + if (projectNotes.value.find((n) => n.id === id)) { + openNote(id).catch(() => {}); + } + } +}); + onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); }); @@ -467,41 +446,278 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); }); diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue index db839d5..f57fc62 100644 --- a/frontend/src/components/WorkspaceTaskPanel.vue +++ b/frontend/src/components/WorkspaceTaskPanel.vue @@ -56,6 +56,17 @@ const STATUS_ICON: Record = { done: "✓", }; +const PRIORITY_CLASS: Record = { + high: "priority-high", + medium: "priority-medium", + low: "priority-low", +}; + +function isRowOverdue(task: Task): boolean { + if (!task.due_date || task.status === "done") return false; + return task.due_date < new Date().toISOString().slice(0, 10); +} + // Group tasks by milestone, "No Milestone" first const groupedTasks = computed(() => { const noMs = tasks.value.filter((t) => t.milestone_id === null); @@ -207,45 +218,99 @@ defineExpose({ reload: loadAll }); @@ -370,9 +351,16 @@ defineExpose({ reload: loadAll }); .task-list-view { display: flex; flex-direction: column; - height: 100%; + flex: 1; + min-height: 0; overflow: hidden; } +.task-list-view.has-detail { + flex: 0 0 44%; +} +.task-active { + background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)) !important; +} .panel-header { padding: 0.6rem 0.75rem; @@ -510,15 +498,15 @@ defineExpose({ reload: loadAll }); .state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); } -/* ── Slide-over detail ── */ +/* ── Detail pane (bottom split) ── */ .task-detail { - position: absolute; - inset: 0; - background: var(--color-surface); + flex: 1; + min-height: 0; display: flex; flex-direction: column; overflow: hidden; - z-index: 5; + background: var(--color-surface); + border-top: 2px solid var(--color-border); } .detail-header { @@ -530,17 +518,6 @@ defineExpose({ reload: loadAll }); flex-shrink: 0; } -.btn-back-arrow { - background: none; - border: none; - color: var(--color-primary); - font-size: 0.85rem; - cursor: pointer; - padding: 0; - flex-shrink: 0; -} -.btn-back-arrow:hover { text-decoration: underline; } - .status-badge { padding: 0.2rem 0.55rem; border-radius: 12px; @@ -669,11 +646,45 @@ defineExpose({ reload: loadAll }); border-top: 1px solid var(--color-border); } -/* Slide transition */ -.slide-enter-active, -.slide-leave-active { - transition: transform 0.2s ease; +/* Detail fade transition */ +.detail-fade-enter-active, +.detail-fade-leave-active { transition: opacity 0.15s; } +.detail-fade-enter-from, +.detail-fade-leave-to { opacity: 0; } + +/* Priority dots on task rows */ +.priority-dot { + width: 6px; + height: 6px; + border-radius: 2px; + flex-shrink: 0; } -.slide-enter-from { transform: translateX(100%); } -.slide-leave-to { transform: translateX(100%); } +.priority-high { background: #ef4444; } +.priority-medium { background: #f59e0b; } +.priority-low { background: #3b82f6; } + +/* Due date on task rows */ +.task-due { + font-size: 0.65rem; + color: var(--color-text-muted); + white-space: nowrap; + flex-shrink: 0; +} +.task-due.overdue { + color: var(--color-danger, #e74c3c); + font-weight: 600; +} + +/* Close detail button */ +.btn-close-detail { + background: none; + border: none; + color: var(--color-text-muted); + font-size: 0.75rem; + cursor: pointer; + padding: 0.15rem 0.3rem; + border-radius: 3px; + margin-left: 0.2rem; +} +.btn-close-detail:hover { color: var(--color-text); } diff --git a/frontend/src/extensions/SlashCommands.ts b/frontend/src/extensions/SlashCommands.ts index 579deb7..aee1768 100644 --- a/frontend/src/extensions/SlashCommands.ts +++ b/frontend/src/extensions/SlashCommands.ts @@ -1,4 +1,4 @@ -import { Extension } from "@tiptap/vue-3"; +import { Extension } from "@tiptap/core"; import { PluginKey } from "@tiptap/pm/state"; import Suggestion from "@tiptap/suggestion"; import { createSuggestionRenderer } from "./suggestionRenderer"; diff --git a/frontend/src/extensions/TagDecoration.ts b/frontend/src/extensions/TagDecoration.ts index 7512a95..c35d099 100644 --- a/frontend/src/extensions/TagDecoration.ts +++ b/frontend/src/extensions/TagDecoration.ts @@ -1,4 +1,4 @@ -import { Extension } from "@tiptap/vue-3"; +import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; diff --git a/frontend/src/extensions/TagSuggestion.ts b/frontend/src/extensions/TagSuggestion.ts index eed4664..6b240c1 100644 --- a/frontend/src/extensions/TagSuggestion.ts +++ b/frontend/src/extensions/TagSuggestion.ts @@ -1,4 +1,4 @@ -import { Extension } from "@tiptap/vue-3"; +import { Extension } from "@tiptap/core"; import { PluginKey } from "@tiptap/pm/state"; import Suggestion from "@tiptap/suggestion"; import { createSuggestionRenderer } from "./suggestionRenderer"; diff --git a/frontend/src/extensions/WikilinkDecoration.ts b/frontend/src/extensions/WikilinkDecoration.ts index c2096b3..71805bb 100644 --- a/frontend/src/extensions/WikilinkDecoration.ts +++ b/frontend/src/extensions/WikilinkDecoration.ts @@ -1,4 +1,4 @@ -import { Extension } from "@tiptap/vue-3"; +import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; diff --git a/frontend/src/extensions/WikilinkSuggestion.ts b/frontend/src/extensions/WikilinkSuggestion.ts index 7b53bd0..a761106 100644 --- a/frontend/src/extensions/WikilinkSuggestion.ts +++ b/frontend/src/extensions/WikilinkSuggestion.ts @@ -1,4 +1,4 @@ -import { Extension } from "@tiptap/vue-3"; +import { Extension } from "@tiptap/core"; import { PluginKey } from "@tiptap/pm/state"; import Suggestion from "@tiptap/suggestion"; import { createSuggestionRenderer } from "./suggestionRenderer"; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bff27a4..3c99546 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -105,21 +105,23 @@ const router = createRouter({ name: "chat-conversation", component: () => import("@/views/ChatView.vue"), }, + { + path: "/shared", + name: "shared-with-me", + component: () => import("@/views/SharedWithMeView.vue"), + }, + { + path: "/briefing", + name: "briefing", + component: () => import("@/views/BriefingView.vue"), + }, { path: "/settings", name: "settings", component: () => import("@/views/SettingsView.vue"), }, - { - path: "/admin/users", - name: "admin-users", - component: () => import("@/views/UserManagementView.vue"), - }, - { - path: "/admin/logs", - name: "admin-logs", - component: () => import("@/views/LogsView.vue"), - }, + { path: "/admin/users", redirect: "/settings" }, + { path: "/admin/logs", redirect: "/settings" }, ], }); diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 93d72e2..7d2e83f 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -30,12 +30,24 @@ interface ConvStreamState { contextMeta: ContextMeta | null; } +interface QueuedMessage { + content: string; + contextNoteId?: number | null; + includeNoteIds?: number[]; + think: boolean; + contextNoteTitle?: string; + excludeNoteIds?: number[]; + ragProjectId?: number | null; + workspaceProjectId?: number | null; +} + export const useChatStore = defineStore("chat", () => { const conversations = ref([]); const currentConversation = ref(null); const total = ref(0); const loading = ref(false); const convStreams = ref>({}); + const convQueues = ref>({}); const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking"); const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking"); const defaultModel = ref(""); @@ -67,6 +79,44 @@ export const useChatStore = defineStore("chat", () => { return convStreams.value[id]?.streaming ?? false; } + const queuedCount = computed(() => { + const id = currentConversation.value?.id; + return id ? (convQueues.value[id]?.length ?? 0) : 0; + }); + + const queuedMessages = computed(() => { + const id = currentConversation.value?.id; + return id ? (convQueues.value[id] ?? []) : []; + }); + + function _saveQueue(convId: number) { + const q = convQueues.value[convId] ?? []; + if (q.length) { + localStorage.setItem(`fa_conv_queue_${convId}`, JSON.stringify(q)); + } else { + localStorage.removeItem(`fa_conv_queue_${convId}`); + } + } + + function _loadQueue(convId: number) { + try { + const raw = localStorage.getItem(`fa_conv_queue_${convId}`); + if (raw) { + convQueues.value[convId] = JSON.parse(raw) as QueuedMessage[]; + } + } catch { + // ignore corrupt data + } + } + + function clearQueue() { + const id = currentConversation.value?.id; + if (id) { + convQueues.value[id] = []; + _saveQueue(id); + } + } + // Chat is usable when Ollama is up and model is at least installed (cold starts still work) const chatReady = computed( () => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold") @@ -107,6 +157,7 @@ export const useChatStore = defineStore("chat", () => { currentConversation.value = await apiGet( `/api/chat/conversations/${id}` ); + _loadQueue(id); } catch (e) { useToastStore().show("Failed to load conversation", "error"); throw e; @@ -123,7 +174,11 @@ export const useChatStore = defineStore("chat", () => { if (currentConversation.value && idSet.has(currentConversation.value.id)) { currentConversation.value = null; } - for (const id of ids) delete convStreams.value[id]; + for (const id of ids) { + delete convStreams.value[id]; + delete convQueues.value[id]; + localStorage.removeItem(`fa_conv_queue_${id}`); + } } async function deleteConversation(id: number) { @@ -134,6 +189,8 @@ export const useChatStore = defineStore("chat", () => { currentConversation.value = null; } delete convStreams.value[id]; + delete convQueues.value[id]; + localStorage.removeItem(`fa_conv_queue_${id}`); } catch (e) { useToastStore().show("Failed to delete conversation", "error"); throw e; @@ -173,6 +230,18 @@ export const useChatStore = defineStore("chat", () => { const convId = currentConversation.value.id; const s = _getOrInitStream(convId); + + // If already streaming, queue the message for after the current stream ends. + if (s.streaming) { + if (!convQueues.value[convId]) convQueues.value[convId] = []; + convQueues.value[convId].push({ + content, contextNoteId, includeNoteIds, think, contextNoteTitle, + excludeNoteIds, ragProjectId, workspaceProjectId, + }); + _saveQueue(convId); + return; + } + s.contextMeta = null; // If a write tool is waiting for confirmation, intercept single-word yes/no @@ -361,6 +430,24 @@ export const useChatStore = defineStore("chat", () => { s.status = ""; s.pendingTool = null; } + + // Process next queued message, if any. + // Use setTimeout so this frame resolves before the next send begins. + const queue = convQueues.value[convId]; + if (queue?.length && currentConversation.value?.id === convId) { + const next = queue.shift()!; + _saveQueue(convId); + setTimeout(() => sendMessage( + next.content, + next.contextNoteId, + next.includeNoteIds, + next.think, + next.contextNoteTitle, + next.excludeNoteIds, + next.ragProjectId, + next.workspaceProjectId, + ), 0); + } } async function reconnectIfGenerating(convId: number): Promise { @@ -501,6 +588,9 @@ export const useChatStore = defineStore("chat", () => { defaultModel, chatReady, isStreamingConv, + queuedCount, + queuedMessages, + clearQueue, fetchConversations, createConversation, fetchConversation, diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts new file mode 100644 index 0000000..9364ca4 --- /dev/null +++ b/frontend/src/stores/notifications.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { + getNotificationCount, + getNotifications, + markAllNotificationsRead, + markNotificationRead, + type NotificationEntry, +} from '@/api/client' + +export const useNotificationsStore = defineStore('notifications', () => { + const count = ref(0) + const items = ref([]) + + async function fetchCount() { + try { + count.value = await getNotificationCount() + } catch { + // silently ignore — polling failure shouldn't surface to user + } + } + + async function fetchAll() { + try { + items.value = await getNotifications(false) + count.value = items.value.length + } catch { + // + } + } + + async function markRead(id: number) { + await markNotificationRead(id) + items.value = items.value.filter(n => n.id !== id) + count.value = Math.max(0, count.value - 1) + } + + async function markAll() { + await markAllNotificationsRead() + items.value = [] + count.value = 0 + } + + return { count, items, fetchCount, fetchAll, markRead, markAll } +}) diff --git a/frontend/src/stores/push.ts b/frontend/src/stores/push.ts index 61d9990..d6e90fa 100644 --- a/frontend/src/stores/push.ts +++ b/frontend/src/stores/push.ts @@ -1,11 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -function urlBase64ToUint8Array(base64String: string): Uint8Array { +function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const rawData = window.atob(base64) - const outputArray = new Uint8Array(rawData.length) + const buf = new ArrayBuffer(rawData.length) + const outputArray = new Uint8Array(buf) for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i) } diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index be65bdb..ad430ea 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -9,7 +9,7 @@ export interface GenerationTiming { export interface ToolCallRecord { function: string; arguments: Record; - result: { success: boolean; type?: string; data?: Record; error?: string; suggested_tags?: string[] }; + result: { success: boolean; type?: string; data?: Record; error?: string; suggested_tags?: string[]; requires_confirmation?: boolean; similar_note?: { id: number; title: string } }; status: "running" | "success" | "error" | "declined"; } diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 925d626..44672d9 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -1,4 +1,4 @@ -import { marked } from "marked"; +import { marked, type RendererThis, type Tokens } from "marked"; import DOMPurify from "dompurify"; import { linkifyTags, linkifyWikilinks } from "@/utils/tags"; @@ -28,7 +28,8 @@ export function stripFirstLineTags(text: string): string { } const headingRenderer = { - heading({ text, depth }: { text: string; depth: number }): string { + heading(this: RendererThis, { tokens, depth }: Tokens.Heading): string { + const text = this.parser.parseInline(tokens); const id = slugify(text); return `${text}`; }, diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue new file mode 100644 index 0000000..4a5fa64 --- /dev/null +++ b/frontend/src/views/BriefingView.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index 8cc03ad..8318baf 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -114,7 +114,8 @@ const streamingRendered = computed(() => { const inputPlaceholder = computed(() => { if (!store.chatReady) return "Chat unavailable"; - return "Type a message... (Enter to send, Shift+Enter for new line)"; + if (store.streaming) return "Type to queue next message… (Enter to queue)"; + return "Type a message… (Enter to send, Shift+Enter for new line)"; }); onMounted(async () => { @@ -283,7 +284,7 @@ async function removeConversation(id: number) { async function sendMessage() { const content = messageInput.value.trim(); - if (!content || store.streaming) return; + if (!content) return; // Auto-create conversation if none selected if (!store.currentConversation) { @@ -475,6 +476,13 @@ function onGlobalKeydown(e: KeyboardEvent) { onUnmounted(() => { document.removeEventListener("keydown", onGlobalKeydown); + // Clean up empty conversation when navigating away from the chat view entirely + if (prevConvId) { + const conv = store.conversations.find((c) => c.id === prevConvId); + if (conv && conv.message_count === 0) { + store.deleteConversation(prevConvId); + } + } }); @@ -631,6 +639,25 @@ onUnmounted(() => { + + +

{ @keydown="onInputKeydown" @input="autoResize" :placeholder="inputPlaceholder" - :disabled="store.streaming || !store.chatReady" + :disabled="!store.chatReady" rows="1" >