commit 22a3a3c1d1c287bd37318b89e343b5051ed52d66 Author: Bryan Van Deusen Date: Mon Feb 9 23:35:44 2026 -0500 Initial commit: note-taking/task-tracking app with LLM integration scaffold Vue 3 + TypeScript frontend with Pinia stores, markdown rendering (marked + DOMPurify), wikilink/tag linkification, and autocomplete. Quart async backend with SQLAlchemy 2.0, PostgreSQL ARRAY columns, task-note companion linking, backlinks, and note-to-task conversion. Docker Compose setup with PostgreSQL 16 and Ollama. Co-Authored-By: Claude Opus 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5c830b8 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +POSTGRES_USER=fabled +POSTGRES_PASSWORD=fabled +POSTGRES_DB=fabledassistant +SECRET_KEY=dev-secret-change-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1deb830 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg +.venv/ +venv/ + +# Node +node_modules/ +frontend/dist/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env + +# Docker +docker-compose.override.yml + +# Misc +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6e343d4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Stage 1: Build Vue frontend +FROM node:20-alpine AS build-frontend +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ . +RUN npm run build + +# Stage 2: Python runtime +FROM python:3.12-slim AS runtime +WORKDIR /app + +COPY pyproject.toml . +COPY src/ src/ +RUN pip install --no-cache-dir . + +COPY --from=build-frontend /build/dist/ src/fabledassistant/static/ +COPY alembic.ini . +COPY alembic/ alembic/ + +# Ensure Python finds the source tree (where static files live) before site-packages +ENV PYTHONPATH=/app/src + +EXPOSE 5000 +CMD ["hypercorn", "fabledassistant.app:create_app()", "--bind", "0.0.0.0:5000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c69081d --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: build up down logs health migrate + +build: + docker compose build + +up: + docker compose up -d + +down: + docker compose down + +logs: + docker compose logs -f + +health: + curl -s http://localhost:5000/api/health + +migrate: + docker compose exec app alembic upgrade head diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..cf176ec --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +prepend_sys_path = src + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..badf43c --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,48 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from fabledassistant.config import Config +from fabledassistant.models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=Config.DATABASE_URL, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + engine = create_async_engine(Config.DATABASE_URL) + async with engine.connect() as connection: + await connection.run_sync(do_run_migrations) + await engine.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/alembic/versions/0002_create_tasks_table.py b/alembic/versions/0002_create_tasks_table.py new file mode 100644 index 0000000..32352df --- /dev/null +++ b/alembic/versions/0002_create_tasks_table.py @@ -0,0 +1,76 @@ +"""create tasks table + +Revision ID: 0002 +Revises: +Create Date: 2025-01-01 00:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ARRAY + +revision = "0002" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + task_status = sa.Enum("todo", "in_progress", "done", name="task_status") + task_status.create(op.get_bind(), checkfirst=True) + + task_priority = sa.Enum("none", "low", "medium", "high", name="task_priority") + task_priority.create(op.get_bind(), checkfirst=True) + + op.create_table( + "tasks", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.Text(), nullable=False, server_default=""), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.Column( + "status", + task_status, + nullable=False, + server_default="todo", + ), + sa.Column( + "priority", + task_priority, + nullable=False, + server_default="none", + ), + sa.Column("due_date", sa.Date(), nullable=True), + sa.Column( + "note_id", + sa.Integer(), + sa.ForeignKey("notes.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default="{}"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + op.create_index("ix_tasks_tags", "tasks", ["tags"], postgresql_using="gin") + op.create_index("ix_tasks_note_id", "tasks", ["note_id"]) + op.create_index("ix_tasks_status", "tasks", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_tasks_status", table_name="tasks") + op.drop_index("ix_tasks_note_id", table_name="tasks") + op.drop_index("ix_tasks_tags", table_name="tasks") + op.drop_table("tasks") + + sa.Enum(name="task_priority").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="task_status").drop(op.get_bind(), checkfirst=True) diff --git a/alembic/versions/0003_task_note_companion.py b/alembic/versions/0003_task_note_companion.py new file mode 100644 index 0000000..371058e --- /dev/null +++ b/alembic/versions/0003_task_note_companion.py @@ -0,0 +1,48 @@ +"""create companion notes for existing tasks + +Revision ID: 0003 +Revises: 0002 +Create Date: 2025-01-01 00:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Find tasks that don't have a companion note + tasks = conn.execute( + sa.text("SELECT id, title, tags FROM tasks WHERE note_id IS NULL") + ).fetchall() + + for task in tasks: + # Create a companion note + result = conn.execute( + sa.text( + "INSERT INTO notes (title, body, tags, created_at, updated_at) " + "VALUES (:title, '', :tags, NOW(), NOW()) RETURNING id" + ), + {"title": task.title, "tags": task.tags}, + ) + note_id = result.scalar() + + # Link the task to its companion note + conn.execute( + sa.text("UPDATE tasks SET note_id = :note_id WHERE id = :task_id"), + {"note_id": note_id, "task_id": task.id}, + ) + + +def downgrade() -> None: + # Remove companion notes that were created by this migration + # (notes linked to tasks that were previously unlinked) + # This is a best-effort downgrade - we can't perfectly distinguish + # migration-created notes from user-created ones + pass diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7da3517 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + app: + build: . + ports: + - "5000:5000" + depends_on: + db: + condition: service_healthy + ollama: + condition: service_started + environment: + DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}" + OLLAMA_URL: "http://ollama:11434" + SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" + + db: + image: postgres:16-alpine + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_USER: ${POSTGRES_USER:-fabled} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled} + POSTGRES_DB: ${POSTGRES_DB:-fabledassistant} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"] + interval: 5s + timeout: 5s + retries: 5 + + ollama: + image: ollama/ollama + volumes: + - ollama_models:/root/.ollama + # To enable GPU support, uncomment the deploy section below + # (requires nvidia-container-toolkit) + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + +volumes: + pgdata: + ollama_models: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..72096c6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Fabled Assistant + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2298e73 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1571 @@ +{ + "name": "fabledassistant-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fabledassistant-frontend", + "version": "0.1.0", + "dependencies": { + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.1.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "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", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "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" + } + }, + "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" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "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/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "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" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "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" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "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==", + "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" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a40e271 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "fabledassistant-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "pinia": "^2.2.0", + "vue": "^3.5.0", + "marked": "^15.0.0", + "dompurify": "^3.1.0", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@types/dompurify": "^3.0.0", + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f37049a --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..d799386 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,50 @@ +export async function apiGet(path: string): Promise { + const res = await fetch(path); + if (!res.ok) { + throw new Error(`API error: ${res.status}`); + } + return res.json() as Promise; +} + +export async function apiPost(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`API error: ${res.status}`); + } + return res.json() as Promise; +} + +export async function apiPut(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`API error: ${res.status}`); + } + return res.json() as Promise; +} + +export async function apiPatch(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`API error: ${res.status}`); + } + return res.json() as Promise; +} + +export async function apiDelete(path: string): Promise { + const res = await fetch(path, { method: "DELETE" }); + if (!res.ok) { + throw new Error(`API error: ${res.status}`); + } +} diff --git a/frontend/src/assets/prose.css b/frontend/src/assets/prose.css new file mode 100644 index 0000000..2fdcef4 --- /dev/null +++ b/frontend/src/assets/prose.css @@ -0,0 +1,145 @@ +.prose { + line-height: 1.6; +} + +.prose h1 { + margin: 1.25rem 0 0.5rem; + font-size: 1.5rem; +} + +.prose h2 { + margin: 1rem 0 0.4rem; + font-size: 1.3rem; +} + +.prose h3 { + margin: 0.75rem 0 0.3rem; + font-size: 1.1rem; +} + +.prose h4, +.prose h5, +.prose h6 { + margin: 0.5rem 0 0.25rem; +} + +.prose p { + margin: 0 0 0.6rem; +} + +.prose ul, +.prose ol { + margin: 0 0 0.6rem; + padding-left: 1.5rem; +} + +.prose li { + margin-bottom: 0.2rem; +} + +.prose pre { + background: var(--color-code-bg); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.75rem; + overflow-x: auto; + margin: 0 0 0.6rem; +} + +.prose pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: 0.9em; +} + +.prose code { + background: var(--color-code-inline-bg); + border-radius: 3px; + padding: 0.15rem 0.35rem; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + font-size: 0.9em; +} + +.prose table { + border-collapse: collapse; + width: 100%; + margin: 0 0 0.6rem; +} + +.prose th, +.prose td { + border: 1px solid var(--color-border); + padding: 0.4rem 0.6rem; + text-align: left; +} + +.prose thead th { + background: var(--color-bg-secondary); + font-weight: 600; +} + +.prose tbody tr:nth-child(even) { + background: var(--color-table-stripe); +} + +.prose blockquote { + border-left: 3px solid var(--color-border); + margin: 0 0 0.6rem; + padding: 0.25rem 0 0.25rem 0.75rem; + color: var(--color-text-secondary); +} + +.prose blockquote p:last-child { + margin-bottom: 0; +} + +.prose hr { + border: none; + border-top: 1px solid var(--color-border); + margin: 1rem 0; +} + +.prose img { + max-width: 100%; + height: auto; +} + +.prose a { + color: var(--color-primary); + text-decoration: none; +} + +.prose a:hover { + text-decoration: underline; +} + +.prose .inline-tag { + color: var(--color-tag-text); + background: var(--color-tag-bg); + padding: 0.1rem 0.35rem; + border-radius: 4px; + text-decoration: none; + font-size: 0.9em; +} + +.prose .inline-tag:hover { + filter: brightness(0.9); + text-decoration: none; +} + +.prose .wikilink { + color: var(--color-wikilink); + background: var(--color-wikilink-bg); + padding: 0.1rem 0.35rem; + border-radius: 4px; + text-decoration: none; + font-size: 0.9em; + cursor: pointer; +} + +.prose .wikilink:hover { + filter: brightness(0.9); + text-decoration: none; +} diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css new file mode 100644 index 0000000..3748902 --- /dev/null +++ b/frontend/src/assets/theme.css @@ -0,0 +1,87 @@ +:root { + --color-bg: #ffffff; + --color-bg-secondary: #f5f5f5; + --color-bg-card: #ffffff; + --color-text: #1a1a1a; + --color-text-secondary: #666666; + --color-text-muted: #999999; + --color-border: #e0e0e0; + --color-input-border: #cccccc; + --color-primary: #1a73e8; + --color-danger: #d93025; + --color-tag-bg: #e8f0fe; + --color-tag-text: #1a73e8; + --color-shadow: rgba(0, 0, 0, 0.1); + --color-toast-success: #34a853; + --color-toast-error: #d93025; + --color-status-todo: #5f6368; + --color-status-todo-bg: #e8eaed; + --color-status-in-progress: #1a73e8; + --color-status-in-progress-bg: #e8f0fe; + --color-status-done: #34a853; + --color-status-done-bg: #e6f4ea; + --color-priority-low: #5f9ea0; + --color-priority-low-bg: #e0f2f1; + --color-priority-medium: #f9a825; + --color-priority-medium-bg: #fff8e1; + --color-priority-high: #d93025; + --color-priority-high-bg: #fce8e6; + --color-wikilink: #7b1fa2; + --color-wikilink-bg: #f3e5f5; + --color-overdue: #d93025; + --color-code-bg: #f6f8fa; + --color-code-inline-bg: #eff1f3; + --color-table-stripe: #f9f9f9; +} + +[data-theme="dark"] { + --color-bg: #1a1a2e; + --color-bg-secondary: #16213e; + --color-bg-card: #1f2940; + --color-text: #e0e0e0; + --color-text-secondary: #a0a0b0; + --color-text-muted: #707080; + --color-border: #2a3a5c; + --color-input-border: #3a4a6c; + --color-primary: #5b9cf6; + --color-danger: #f44336; + --color-tag-bg: #1e3a5f; + --color-tag-text: #7bb8f6; + --color-shadow: rgba(0, 0, 0, 0.3); + --color-toast-success: #4caf50; + --color-toast-error: #f44336; + --color-status-todo: #9aa0a6; + --color-status-todo-bg: #2d333b; + --color-status-in-progress: #5b9cf6; + --color-status-in-progress-bg: #1e3a5f; + --color-status-done: #4caf50; + --color-status-done-bg: #1b3a20; + --color-priority-low: #80cbc4; + --color-priority-low-bg: #1a3a38; + --color-priority-medium: #fdd835; + --color-priority-medium-bg: #3a3520; + --color-priority-high: #f44336; + --color-priority-high-bg: #3a1a1a; + --color-wikilink: #ce93d8; + --color-wikilink-bg: #2a1a30; + --color-overdue: #f44336; + --color-code-bg: #161b22; + --color-code-inline-bg: #2a3040; + --color-table-stripe: #1a2030; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; + line-height: 1.5; + transition: background-color 0.2s, color 0.2s; +} diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..88f0ac6 --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/src/components/MarkdownToolbar.vue b/frontend/src/components/MarkdownToolbar.vue new file mode 100644 index 0000000..f4aaa1b --- /dev/null +++ b/frontend/src/components/MarkdownToolbar.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue new file mode 100644 index 0000000..708f5c4 --- /dev/null +++ b/frontend/src/components/NoteCard.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/src/components/PaginationBar.vue b/frontend/src/components/PaginationBar.vue new file mode 100644 index 0000000..fd15006 --- /dev/null +++ b/frontend/src/components/PaginationBar.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/components/PriorityBadge.vue b/frontend/src/components/PriorityBadge.vue new file mode 100644 index 0000000..f4f0021 --- /dev/null +++ b/frontend/src/components/PriorityBadge.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/components/SearchBar.vue b/frontend/src/components/SearchBar.vue new file mode 100644 index 0000000..d7f6982 --- /dev/null +++ b/frontend/src/components/SearchBar.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/frontend/src/components/StatusBadge.vue b/frontend/src/components/StatusBadge.vue new file mode 100644 index 0000000..fae4892 --- /dev/null +++ b/frontend/src/components/StatusBadge.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/frontend/src/components/TagPill.vue b/frontend/src/components/TagPill.vue new file mode 100644 index 0000000..f2ecaa1 --- /dev/null +++ b/frontend/src/components/TagPill.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue new file mode 100644 index 0000000..6f12136 --- /dev/null +++ b/frontend/src/components/TaskCard.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/components/ToastNotification.vue b/frontend/src/components/ToastNotification.vue new file mode 100644 index 0000000..4622bf9 --- /dev/null +++ b/frontend/src/components/ToastNotification.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/composables/useAutocomplete.ts b/frontend/src/composables/useAutocomplete.ts new file mode 100644 index 0000000..9724560 --- /dev/null +++ b/frontend/src/composables/useAutocomplete.ts @@ -0,0 +1,259 @@ +import { ref, type Ref } from "vue"; +import { apiGet } from "@/api/client"; +import type { NoteListResponse } from "@/types/note"; + +export interface AutocompleteItem { + label: string; + value: string; +} + +export function useAutocomplete( + textareaRef: Ref, + textRef: Ref, + fetchTags: (q: string) => Promise +) { + const acItems = ref([]); + const acVisible = ref(false); + const acIndex = ref(0); + const acTop = ref(0); + const acLeft = ref(0); + + let acTriggerStart = -1; + let acType: "tag" | "wikilink" | null = null; + let debounceTimer: ReturnType; + + function detectTrigger() { + const el = textareaRef.value; + if (!el) return; + const cursor = el.selectionStart; + const text = textRef.value; + + // Check for [[ trigger + for (let i = cursor - 1; i >= 1; i--) { + if (text[i] === "\n") break; + if (text[i - 1] === "[" && text[i] === "[") { + const query = text.slice(i + 1, cursor); + if (!/\]/.test(query)) { + acTriggerStart = i - 1; + acType = "wikilink"; + debouncedSearch(query); + return; + } + break; + } + } + + // Check for # trigger + for (let i = cursor - 1; i >= 0; i--) { + const ch = text[i]; + if (ch === "\n" || ch === " " || ch === "\t") break; + if (ch === "#") { + // Must be start of line or preceded by whitespace + if (i === 0 || /\s/.test(text[i - 1])) { + const query = text.slice(i + 1, cursor); + if (/^[\w/]*$/.test(query)) { + acTriggerStart = i; + acType = "tag"; + debouncedSearch(query); + return; + } + } + break; + } + } + + dismiss(); + } + + function debouncedSearch(query: string) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => doSearch(query), 200); + } + + async function doSearch(query: string) { + if (acType === "tag") { + try { + const tags = await fetchTags(query); + acItems.value = tags.slice(0, 8).map((t) => ({ + label: `#${t}`, + value: t, + })); + } catch (e) { + console.error("Tag autocomplete fetch failed:", e); + acItems.value = []; + } + } else if (acType === "wikilink") { + try { + const data = await apiGet( + `/api/notes?q=${encodeURIComponent(query)}&limit=8` + ); + acItems.value = data.notes.map((n) => ({ + label: n.title || "Untitled", + value: n.title || "Untitled", + })); + } catch (e) { + console.error("Wikilink autocomplete fetch failed:", e); + acItems.value = []; + } + } + + if (acItems.value.length > 0) { + acIndex.value = 0; + positionDropdown(); + acVisible.value = true; + } else if (acType === "tag") { + // Show "no tags" hint so the user knows the trigger is working + acItems.value = [{ label: "No tags yet", value: "" }]; + acIndex.value = -1; + positionDropdown(); + acVisible.value = true; + } else { + acVisible.value = false; + } + } + + function positionDropdown() { + const el = textareaRef.value; + if (!el) return; + + // Use a mirror div to measure cursor position + const mirror = document.createElement("div"); + const style = getComputedStyle(el); + const props = [ + "fontFamily", + "fontSize", + "fontWeight", + "lineHeight", + "letterSpacing", + "wordSpacing", + "textIndent", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "boxSizing", + "whiteSpace", + "wordWrap", + "overflowWrap", + ] as const; + mirror.style.position = "absolute"; + mirror.style.visibility = "hidden"; + mirror.style.overflow = "hidden"; + mirror.style.width = style.width; + for (const prop of props) { + mirror.style[prop] = style[prop]; + } + + const textBefore = el.value.slice(0, el.selectionStart); + mirror.textContent = textBefore; + const span = document.createElement("span"); + span.textContent = "|"; + mirror.appendChild(span); + + document.body.appendChild(mirror); + const rect = el.getBoundingClientRect(); + const spanRect = span.getBoundingClientRect(); + const mirrorRect = mirror.getBoundingClientRect(); + + acTop.value = + rect.top + (spanRect.top - mirrorRect.top) - el.scrollTop + 20; + acLeft.value = + rect.left + (spanRect.left - mirrorRect.left) - el.scrollLeft; + + document.body.removeChild(mirror); + } + + function accept(index?: number) { + const el = textareaRef.value; + if (!el || !acVisible.value) return; + + const idx = index ?? acIndex.value; + const item = acItems.value[idx]; + if (!item || !item.value) return; + + const cursor = el.selectionStart; + const text = textRef.value; + + let replacement: string; + let end = cursor; + + if (acType === "tag") { + replacement = `#${item.value} `; + } else { + replacement = `[[${item.value}]]`; + } + + textRef.value = + text.slice(0, acTriggerStart) + replacement + text.slice(end); + + const newPos = acTriggerStart + replacement.length; + dismiss(); + + // Restore cursor position after Vue updates the DOM + requestAnimationFrame(() => { + el.setSelectionRange(newPos, newPos); + el.focus(); + }); + } + + function dismiss() { + acVisible.value = false; + acItems.value = []; + acType = null; + acTriggerStart = -1; + clearTimeout(debounceTimer); + } + + function onKeydown(e: KeyboardEvent): boolean { + if (!acVisible.value) return false; + + if (e.key === "ArrowDown") { + e.preventDefault(); + acIndex.value = (acIndex.value + 1) % acItems.value.length; + return true; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + acIndex.value = + (acIndex.value - 1 + acItems.value.length) % acItems.value.length; + return true; + } + if (e.key === "Tab") { + e.preventDefault(); + // Single match: accept immediately. Multiple: cycle then accept on second Tab. + if (acItems.value.length === 1) { + accept(); + } else { + acIndex.value = (acIndex.value + 1) % acItems.value.length; + } + return true; + } + if (e.key === "Enter") { + e.preventDefault(); + accept(); + return true; + } + if (e.key === "Escape") { + e.preventDefault(); + dismiss(); + return true; + } + return false; + } + + return { + acItems, + acVisible, + acIndex, + acTop, + acLeft, + detectTrigger, + accept, + dismiss, + onKeydown, + }; +} diff --git a/frontend/src/composables/useRelativeTime.ts b/frontend/src/composables/useRelativeTime.ts new file mode 100644 index 0000000..ac0f8bd --- /dev/null +++ b/frontend/src/composables/useRelativeTime.ts @@ -0,0 +1,11 @@ +export function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/frontend/src/composables/useTheme.ts b/frontend/src/composables/useTheme.ts new file mode 100644 index 0000000..bb1430d --- /dev/null +++ b/frontend/src/composables/useTheme.ts @@ -0,0 +1,29 @@ +import { ref } from "vue"; + +type Theme = "light" | "dark"; + +const theme = ref(getInitialTheme()); + +function getInitialTheme(): Theme { + const stored = localStorage.getItem("theme"); + if (stored === "light" || stored === "dark") return stored; + if (window.matchMedia("(prefers-color-scheme: light)").matches) return "light"; + return "dark"; +} + +function applyTheme(t: Theme) { + document.documentElement.setAttribute("data-theme", t); + localStorage.setItem("theme", t); +} + +export function useTheme() { + // Apply on first use + applyTheme(theme.value); + + function toggleTheme() { + theme.value = theme.value === "dark" ? "light" : "dark"; + applyTheme(theme.value); + } + + return { theme, toggleTheme }; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..d33f288 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import App from "./App.vue"; +import router from "./router"; +import "./assets/theme.css"; +import "./assets/prose.css"; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..e764f4c --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,55 @@ +import { createRouter, createWebHistory } from "vue-router"; +import HomeView from "@/views/HomeView.vue"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/", + name: "home", + component: HomeView, + }, + { + path: "/notes", + name: "notes", + component: () => import("@/views/NotesListView.vue"), + }, + { + path: "/notes/new", + name: "note-new", + component: () => import("@/views/NoteEditorView.vue"), + }, + { + path: "/notes/:id", + name: "note-view", + component: () => import("@/views/NoteViewerView.vue"), + }, + { + path: "/notes/:id/edit", + name: "note-edit", + component: () => import("@/views/NoteEditorView.vue"), + }, + { + path: "/tasks", + name: "tasks", + component: () => import("@/views/TasksListView.vue"), + }, + { + path: "/tasks/new", + name: "task-new", + component: () => import("@/views/TaskEditorView.vue"), + }, + { + path: "/tasks/:id", + name: "task-view", + component: () => import("@/views/TaskViewerView.vue"), + }, + { + path: "/tasks/:id/edit", + name: "task-edit", + component: () => import("@/views/TaskEditorView.vue"), + }, + ], +}); + +export default router; diff --git a/frontend/src/stores/.gitkeep b/frontend/src/stores/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts new file mode 100644 index 0000000..2b3993e --- /dev/null +++ b/frontend/src/stores/notes.ts @@ -0,0 +1,202 @@ +import { ref } from "vue"; +import { defineStore } from "pinia"; +import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client"; +import type { Note, NoteListResponse } from "@/types/note"; + +export const useNotesStore = defineStore("notes", () => { + const notes = ref([]); + const currentNote = ref(null); + const total = ref(0); + const loading = ref(false); + + // Filter / pagination / sort state + const activeTagFilters = ref([]); + const limit = ref(20); + const offset = ref(0); + const sortField = ref("updated_at"); + const sortOrder = ref<"asc" | "desc">("desc"); + const searchQuery = ref(""); + + async function refresh() { + loading.value = true; + try { + const searchParams = new URLSearchParams(); + if (searchQuery.value) searchParams.set("q", searchQuery.value); + for (const t of activeTagFilters.value) { + searchParams.append("tag", t); + } + searchParams.set("sort", sortField.value); + searchParams.set("order", sortOrder.value); + searchParams.set("limit", String(limit.value)); + searchParams.set("offset", String(offset.value)); + + const qs = searchParams.toString(); + const data = await apiGet( + `/api/notes${qs ? `?${qs}` : ""}` + ); + notes.value = data.notes; + total.value = data.total; + } finally { + loading.value = false; + } + } + + async function fetchNotes(params?: { + q?: string; + tag?: string[]; + sort?: string; + order?: string; + limit?: number; + offset?: number; + }) { + if (params?.q !== undefined) searchQuery.value = params.q || ""; + if (params?.tag) activeTagFilters.value = params.tag; + if (params?.sort) sortField.value = params.sort; + if (params?.order) sortOrder.value = params.order as "asc" | "desc"; + if (params?.limit) limit.value = params.limit; + if (params?.offset !== undefined) offset.value = params.offset; + await refresh(); + } + + async function fetchNote(id: number) { + loading.value = true; + try { + currentNote.value = await apiGet(`/api/notes/${id}`); + } finally { + loading.value = false; + } + } + + async function createNote(data: { + title: string; + body: string; + }): Promise { + const note = await apiPost("/api/notes", data); + return note; + } + + async function updateNote( + id: number, + data: Partial> + ): Promise { + const note = await apiPut(`/api/notes/${id}`, data); + if (currentNote.value?.id === id) { + currentNote.value = note; + } + return note; + } + + async function deleteNote(id: number) { + await apiDelete(`/api/notes/${id}`); + notes.value = notes.value.filter((n) => n.id !== id); + if (currentNote.value?.id === id) { + currentNote.value = null; + } + } + + function addTagFilter(tag: string) { + if (!activeTagFilters.value.includes(tag)) { + activeTagFilters.value.push(tag); + offset.value = 0; + refresh(); + } + } + + function removeTagFilter(tag: string) { + activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag); + offset.value = 0; + refresh(); + } + + function clearTagFilters() { + activeTagFilters.value = []; + offset.value = 0; + refresh(); + } + + function setTagFilters(tags: string[]) { + activeTagFilters.value = [...tags]; + offset.value = 0; + refresh(); + } + + function setSort(field: string, order: "asc" | "desc") { + sortField.value = field; + sortOrder.value = order; + offset.value = 0; + refresh(); + } + + function setOffset(newOffset: number) { + offset.value = newOffset; + refresh(); + } + + function setSearch(q: string) { + searchQuery.value = q; + offset.value = 0; + refresh(); + } + + async function resolveTitle(title: string): Promise { + return await apiPost("/api/notes/resolve-title", { title }); + } + + async function convertToTask(noteId: number) { + const task = await apiPost>( + `/api/notes/${noteId}/convert-to-task`, + {} + ); + // Remove the note from local state since it was converted + notes.value = notes.value.filter((n) => n.id !== noteId); + if (currentNote.value?.id === noteId) { + currentNote.value = null; + } + return task; + } + + async function fetchBacklinks( + noteId: number + ): Promise<{ type: string; id: number; title: string }[]> { + const data = await apiGet<{ + backlinks: { type: string; id: number; title: string }[]; + }>(`/api/notes/${noteId}/backlinks`); + return data.backlinks; + } + + async function fetchAllTags(q?: string): Promise { + const params = q ? `?q=${encodeURIComponent(q)}` : ""; + const data = await apiGet<{ tags: string[] }>(`/api/notes/tags${params}`); + return data.tags; + } + + return { + notes, + currentNote, + total, + loading, + activeTagFilters, + limit, + offset, + sortField, + sortOrder, + searchQuery, + fetchNotes, + fetchNote, + createNote, + updateNote, + deleteNote, + addTagFilter, + removeTagFilter, + clearTagFilters, + setTagFilters, + setSort, + setOffset, + setSearch, + refresh, + resolveTitle, + convertToTask, + fetchBacklinks, + fetchAllTags, + }; +}); diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts new file mode 100644 index 0000000..3a24b7a --- /dev/null +++ b/frontend/src/stores/tasks.ts @@ -0,0 +1,180 @@ +import { ref } from "vue"; +import { defineStore } from "pinia"; +import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client"; +import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task"; + +export const useTasksStore = defineStore("tasks", () => { + const tasks = ref([]); + const currentTask = ref(null); + const total = ref(0); + const loading = ref(false); + + // Filter / pagination / sort state + const activeTagFilters = ref([]); + const statusFilter = ref(""); + const priorityFilter = ref(""); + const limit = ref(20); + const offset = ref(0); + const sortField = ref("updated_at"); + const sortOrder = ref<"asc" | "desc">("desc"); + const searchQuery = ref(""); + + async function refresh() { + loading.value = true; + try { + const searchParams = new URLSearchParams(); + if (searchQuery.value) searchParams.set("q", searchQuery.value); + for (const t of activeTagFilters.value) { + searchParams.append("tag", t); + } + if (statusFilter.value) searchParams.set("status", statusFilter.value); + if (priorityFilter.value) + searchParams.set("priority", priorityFilter.value); + searchParams.set("sort", sortField.value); + searchParams.set("order", sortOrder.value); + searchParams.set("limit", String(limit.value)); + searchParams.set("offset", String(offset.value)); + + const qs = searchParams.toString(); + const data = await apiGet( + `/api/tasks${qs ? `?${qs}` : ""}` + ); + tasks.value = data.tasks; + total.value = data.total; + } finally { + loading.value = false; + } + } + + async function fetchTask(id: number) { + loading.value = true; + try { + currentTask.value = await apiGet(`/api/tasks/${id}`); + } finally { + loading.value = false; + } + } + + async function createTask(data: { + title: string; + description: string; + status?: TaskStatus; + priority?: TaskPriority; + due_date?: string | null; + }): Promise { + return await apiPost("/api/tasks", data); + } + + async function updateTask( + id: number, + data: Partial< + Pick + > + ): Promise { + const task = await apiPut(`/api/tasks/${id}`, data); + if (currentTask.value?.id === id) { + currentTask.value = task; + } + return task; + } + + async function patchStatus(id: number, status: TaskStatus): Promise { + const task = await apiPatch(`/api/tasks/${id}/status`, { status }); + // Update in list if present + const idx = tasks.value.findIndex((t) => t.id === id); + if (idx !== -1) { + tasks.value[idx] = task; + } + if (currentTask.value?.id === id) { + currentTask.value = task; + } + return task; + } + + async function deleteTask(id: number) { + await apiDelete(`/api/tasks/${id}`); + tasks.value = tasks.value.filter((t) => t.id !== id); + if (currentTask.value?.id === id) { + currentTask.value = null; + } + } + + function setStatusFilter(status: TaskStatus | "") { + statusFilter.value = status; + offset.value = 0; + refresh(); + } + + function setPriorityFilter(priority: TaskPriority | "") { + priorityFilter.value = priority; + offset.value = 0; + refresh(); + } + + function addTagFilter(tag: string) { + if (!activeTagFilters.value.includes(tag)) { + activeTagFilters.value.push(tag); + offset.value = 0; + refresh(); + } + } + + function removeTagFilter(tag: string) { + activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag); + offset.value = 0; + refresh(); + } + + function clearTagFilters() { + activeTagFilters.value = []; + offset.value = 0; + refresh(); + } + + function setSort(field: string, order: "asc" | "desc") { + sortField.value = field; + sortOrder.value = order; + offset.value = 0; + refresh(); + } + + function setOffset(newOffset: number) { + offset.value = newOffset; + refresh(); + } + + function setSearch(q: string) { + searchQuery.value = q; + offset.value = 0; + refresh(); + } + + return { + tasks, + currentTask, + total, + loading, + activeTagFilters, + statusFilter, + priorityFilter, + limit, + offset, + sortField, + sortOrder, + searchQuery, + refresh, + fetchTask, + createTask, + updateTask, + patchStatus, + deleteTask, + setStatusFilter, + setPriorityFilter, + addTagFilter, + removeTagFilter, + clearTagFilters, + setSort, + setOffset, + setSearch, + }; +}); diff --git a/frontend/src/stores/toast.ts b/frontend/src/stores/toast.ts new file mode 100644 index 0000000..ced0f40 --- /dev/null +++ b/frontend/src/stores/toast.ts @@ -0,0 +1,24 @@ +import { ref } from "vue"; +import { defineStore } from "pinia"; + +export interface Toast { + id: number; + message: string; + type: "success" | "error"; +} + +let nextId = 0; + +export const useToastStore = defineStore("toast", () => { + const toasts = ref([]); + + function show(message: string, type: "success" | "error" = "success") { + const id = nextId++; + toasts.value.push({ id, message, type }); + setTimeout(() => { + toasts.value = toasts.value.filter((t) => t.id !== id); + }, 3000); + } + + return { toasts, show }; +}); diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts new file mode 100644 index 0000000..1ea8bfa --- /dev/null +++ b/frontend/src/types/note.ts @@ -0,0 +1,14 @@ +export interface Note { + id: number; + title: string; + body: string; + tags: string[]; + parent_id: number | null; + created_at: string; + updated_at: string; +} + +export interface NoteListResponse { + notes: Note[]; + total: number; +} diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts new file mode 100644 index 0000000..ada7723 --- /dev/null +++ b/frontend/src/types/task.ts @@ -0,0 +1,20 @@ +export type TaskStatus = "todo" | "in_progress" | "done"; +export type TaskPriority = "none" | "low" | "medium" | "high"; + +export interface Task { + id: number; + title: string; + description: string; + status: TaskStatus; + priority: TaskPriority; + due_date: string | null; + note_id: number | null; + tags: string[]; + created_at: string; + updated_at: string; +} + +export interface TaskListResponse { + tasks: Task[]; + total: number; +} diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts new file mode 100644 index 0000000..79b3b21 --- /dev/null +++ b/frontend/src/utils/markdown.ts @@ -0,0 +1,21 @@ +import { marked } from "marked"; +import DOMPurify from "dompurify"; +import { linkifyTags, linkifyWikilinks } from "@/utils/tags"; + +export function renderMarkdown(text: string): string { + const html = marked(text) as string; + const withTags = linkifyTags(html); + const withLinks = linkifyWikilinks(withTags); + return DOMPurify.sanitize(withLinks, { + ADD_ATTR: ["data-tag", "data-title"], + }); +} + +export function renderPreview(text: string): string { + const html = marked(text) as string; + const withTags = linkifyTags(html); + const withLinks = linkifyWikilinks(withTags); + return DOMPurify.sanitize(withLinks, { + FORBID_TAGS: ["a", "img"], + }); +} diff --git a/frontend/src/utils/tags.ts b/frontend/src/utils/tags.ts new file mode 100644 index 0000000..d72d49b --- /dev/null +++ b/frontend/src/utils/tags.ts @@ -0,0 +1,49 @@ +const CODE_FENCE_RE = /```[\s\S]*?```|`[^`\n]+`/g; +const TAG_RE = /(?/g, ">"); +} + +export function extractTags(body: string): string[] { + const cleaned = body.replace(CODE_FENCE_RE, ""); + const tags = new Set(); + let match; + while ((match = TAG_RE.exec(cleaned)) !== null) { + tags.add(match[1]); + } + TAG_RE.lastIndex = 0; + return [...tags].sort(); +} + +export function linkifyTags(html: string): string { + // Split on code/pre blocks to avoid linkifying inside them + const parts = html.split(/(|)/gi); + return parts + .map((part, i) => { + // Odd indices are code/pre blocks, skip them + if (i % 2 === 1) return part; + return part.replace(TAG_RE, (full, tag) => { + const encoded = encodeURIComponent(tag); + return `${full}`; + }); + }) + .join(""); +} + +const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +export function linkifyWikilinks(html: string): string { + const parts = html.split(/(|)/gi); + return parts + .map((part, i) => { + if (i % 2 === 1) return part; + return part.replace(WIKILINK_RE, (_full, title: string, display?: string) => { + const trimmed = title.trim(); + const label = display || trimmed; + const encoded = encodeURIComponent(trimmed); + return `${escapeHtmlAttr(label)}`; + }); + }) + .join(""); +} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..4e8ceca --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue new file mode 100644 index 0000000..1dddc89 --- /dev/null +++ b/frontend/src/views/NoteEditorView.vue @@ -0,0 +1,429 @@ + + + + + diff --git a/frontend/src/views/NoteViewerView.vue b/frontend/src/views/NoteViewerView.vue new file mode 100644 index 0000000..1ffbbf5 --- /dev/null +++ b/frontend/src/views/NoteViewerView.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/frontend/src/views/NotesListView.vue b/frontend/src/views/NotesListView.vue new file mode 100644 index 0000000..573461a --- /dev/null +++ b/frontend/src/views/NotesListView.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue new file mode 100644 index 0000000..5839204 --- /dev/null +++ b/frontend/src/views/TaskEditorView.vue @@ -0,0 +1,537 @@ + + + + + diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue new file mode 100644 index 0000000..d6cef7b --- /dev/null +++ b/frontend/src/views/TaskViewerView.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/frontend/src/views/TasksListView.vue b/frontend/src/views/TasksListView.vue new file mode 100644 index 0000000..e16180f --- /dev/null +++ b/frontend/src/views/TasksListView.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..cd39087 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent; + export default component; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..80ea24b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..635416f --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "composite": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": false, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ff5a266 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { resolve } from "path"; + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + server: { + proxy: { + "/api": "http://localhost:5000", + }, + }, +}); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..db8e8b0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68.0", "setuptools-scm>=8.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "fabledassistant" +version = "0.1.0" +description = "Self-hosted note-taking and task-tracking app with LLM integration" +requires-python = ">=3.12" +dependencies = [ + "quart>=0.19", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.29", + "alembic>=1.13", + "httpx>=0.27", + "hypercorn>=0.17", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", +] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/fabledassistant/__init__.py b/src/fabledassistant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py new file mode 100644 index 0000000..6b1ec17 --- /dev/null +++ b/src/fabledassistant/app.py @@ -0,0 +1,57 @@ +import logging +from pathlib import Path + +from quart import Quart, jsonify, make_response, request, send_from_directory + +from fabledassistant.config import Config +from fabledassistant.routes.api import api +from fabledassistant.routes.notes import notes_bp +from fabledassistant.routes.tasks import tasks_bp + +STATIC_DIR = Path(__file__).parent / "static" +logger = logging.getLogger(__name__) + + +def create_app() -> Quart: + app = Quart(__name__, static_folder=None) + app.secret_key = Config.SECRET_KEY + + app.register_blueprint(api) + app.register_blueprint(notes_bp) + app.register_blueprint(tasks_bp) + + @app.route("/") + async def serve_index(): + resp = await make_response( + await send_from_directory(STATIC_DIR, "index.html") + ) + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return resp + + @app.errorhandler(404) + async def handle_404(error): + # Return JSON 404 for API routes + if request.path.startswith("/api/"): + return jsonify({"error": "Not found"}), 404 + # Try to serve static file + path = request.path.lstrip("/") + file_path = STATIC_DIR / path + if path and file_path.is_file(): + return await send_from_directory(STATIC_DIR, path) + # SPA fallback + resp = await make_response( + await send_from_directory(STATIC_DIR, "index.html") + ) + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return resp + + @app.errorhandler(500) + async def handle_500(error): + import traceback + traceback.print_exc() + logger.exception("Internal server error on %s %s", request.method, request.path) + if request.path.startswith("/api/"): + return jsonify({"error": str(error)}), 500 + return "Internal Server Error", 500 + + return app diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py new file mode 100644 index 0000000..dc8aafa --- /dev/null +++ b/src/fabledassistant/config.py @@ -0,0 +1,10 @@ +import os + + +class Config: + DATABASE_URL: str = os.environ.get( + "DATABASE_URL", + "postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant", + ) + OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434") + SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-change-me") diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py new file mode 100644 index 0000000..7bdbf63 --- /dev/null +++ b/src/fabledassistant/models/__init__.py @@ -0,0 +1,15 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from fabledassistant.config import Config + +engine = create_async_engine(Config.DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +from fabledassistant.models.note import Note # noqa: E402, F401 +from fabledassistant.models.task import Task # noqa: E402, F401 diff --git a/src/fabledassistant/models/note.py b/src/fabledassistant/models/note.py new file mode 100644 index 0000000..bb45752 --- /dev/null +++ b/src/fabledassistant/models/note.py @@ -0,0 +1,40 @@ +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Index, Text +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import Mapped, mapped_column + +from fabledassistant.models import Base + + +class Note(Base): + __tablename__ = "notes" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(Text, default="") + body: Mapped[str] = mapped_column(Text, default="") + tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) + parent_id: Mapped[int | None] = mapped_column( + ForeignKey("notes.id", ondelete="SET NULL"), nullable=True + ) + 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), + ) + + __table_args__ = (Index("ix_notes_tags", "tags", postgresql_using="gin"),) + + def to_dict(self) -> dict: + return { + "id": self.id, + "title": self.title, + "body": self.body, + "tags": self.tags or [], + "parent_id": self.parent_id, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } diff --git a/src/fabledassistant/models/task.py b/src/fabledassistant/models/task.py new file mode 100644 index 0000000..2b35d8f --- /dev/null +++ b/src/fabledassistant/models/task.py @@ -0,0 +1,70 @@ +import enum +from datetime import date, datetime, timezone + +from sqlalchemy import Date, DateTime, Enum, ForeignKey, Index, Text +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import Mapped, mapped_column + +from fabledassistant.models import Base + + +class TaskStatus(str, enum.Enum): + todo = "todo" + in_progress = "in_progress" + done = "done" + + +class TaskPriority(str, enum.Enum): + none = "none" + low = "low" + medium = "medium" + high = "high" + + +class Task(Base): + __tablename__ = "tasks" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(Text, default="") + description: Mapped[str] = mapped_column(Text, default="") + status: Mapped[TaskStatus] = mapped_column( + Enum(TaskStatus, name="task_status", create_constraint=False), + default=TaskStatus.todo, + ) + priority: Mapped[TaskPriority] = mapped_column( + Enum(TaskPriority, name="task_priority", create_constraint=False), + default=TaskPriority.none, + ) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + note_id: Mapped[int | None] = mapped_column( + ForeignKey("notes.id", ondelete="SET NULL"), nullable=True + ) + tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) + 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), + ) + + __table_args__ = ( + Index("ix_tasks_tags", "tags", postgresql_using="gin"), + Index("ix_tasks_note_id", "note_id"), + Index("ix_tasks_status", "status"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "title": self.title, + "description": self.description, + "status": self.status.value, + "priority": self.priority.value, + "due_date": self.due_date.isoformat() if self.due_date else None, + "note_id": self.note_id, + "tags": self.tags or [], + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } diff --git a/src/fabledassistant/routes/__init__.py b/src/fabledassistant/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fabledassistant/routes/api.py b/src/fabledassistant/routes/api.py new file mode 100644 index 0000000..9f8af18 --- /dev/null +++ b/src/fabledassistant/routes/api.py @@ -0,0 +1,8 @@ +from quart import Blueprint, jsonify + +api = Blueprint("api", __name__, url_prefix="/api") + + +@api.route("/health") +async def health(): + return jsonify({"status": "ok"}) diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py new file mode 100644 index 0000000..77c30a1 --- /dev/null +++ b/src/fabledassistant/routes/notes.py @@ -0,0 +1,120 @@ +from quart import Blueprint, jsonify, request + +from fabledassistant.services.notes import ( + convert_note_to_task, + create_note, + delete_note, + get_all_tags, + get_backlinks, + get_note, + get_note_by_title, + get_or_create_note_by_title, + list_notes, + update_note, +) +from fabledassistant.utils.tags import extract_tags + +notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes") + + +@notes_bp.route("", methods=["GET"]) +async def list_notes_route(): + q = request.args.get("q") + tag = request.args.getlist("tag") + sort = request.args.get("sort", "updated_at") + order = request.args.get("order", "desc") + limit = request.args.get("limit", 50, type=int) + offset = request.args.get("offset", 0, type=int) + + notes, total = await list_notes( + q=q, tags=tag or None, sort=sort, order=order, limit=limit, offset=offset + ) + return jsonify({"notes": [n.to_dict() for n in notes], "total": total}) + + +@notes_bp.route("", methods=["POST"]) +async def create_note_route(): + data = await request.get_json() + body = data.get("body", "") + tags = extract_tags(body) + note = await create_note( + title=data.get("title", ""), + body=body, + tags=tags, + parent_id=data.get("parent_id"), + ) + return jsonify(note.to_dict()), 201 + + +@notes_bp.route("/tags", methods=["GET"]) +async def list_tags_route(): + q = request.args.get("q") + tags = await get_all_tags(q=q) + return jsonify({"tags": tags}) + + +@notes_bp.route("/by-title", methods=["GET"]) +async def get_note_by_title_route(): + title = request.args.get("title", "") + if not title: + return jsonify({"error": "title parameter is required"}), 400 + note = await get_note_by_title(title) + if note is None: + return jsonify({"error": "Note not found"}), 404 + return jsonify(note.to_dict()) + + +@notes_bp.route("/resolve-title", methods=["POST"]) +async def resolve_title_route(): + data = await request.get_json() + title = data.get("title", "").strip() + if not title: + return jsonify({"error": "title is required"}), 400 + note = await get_or_create_note_by_title(title) + return jsonify(note.to_dict()) + + +@notes_bp.route("/", methods=["GET"]) +async def get_note_route(note_id: int): + note = await get_note(note_id) + if note is None: + return jsonify({"error": "Note not found"}), 404 + return jsonify(note.to_dict()) + + +@notes_bp.route("/", methods=["PUT"]) +async def update_note_route(note_id: int): + data = await request.get_json() + fields = {} + for key in ("title", "body", "parent_id"): + if key in data: + fields[key] = data[key] + if "body" in fields: + fields["tags"] = extract_tags(fields["body"]) + note = await update_note(note_id, **fields) + if note is None: + return jsonify({"error": "Note not found"}), 404 + return jsonify(note.to_dict()) + + +@notes_bp.route("/", methods=["DELETE"]) +async def delete_note_route(note_id: int): + deleted = await delete_note(note_id) + if not deleted: + return jsonify({"error": "Note not found"}), 404 + return "", 204 + + +@notes_bp.route("//convert-to-task", methods=["POST"]) +async def convert_note_to_task_route(note_id: int): + try: + task = await convert_note_to_task(note_id) + return jsonify(task.to_dict()), 201 + except ValueError as e: + return jsonify({"error": str(e)}), 404 + + +@notes_bp.route("//backlinks", methods=["GET"]) +async def get_backlinks_route(note_id: int): + links = await get_backlinks(note_id) + return jsonify({"backlinks": links}) diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py new file mode 100644 index 0000000..c7884e6 --- /dev/null +++ b/src/fabledassistant/routes/tasks.py @@ -0,0 +1,123 @@ +from datetime import date + +from quart import Blueprint, jsonify, request + +from fabledassistant.models.task import TaskPriority, TaskStatus +from fabledassistant.services.tasks import ( + create_task, + delete_task, + get_task, + list_tasks, + update_task, +) +from fabledassistant.utils.tags import extract_tags + +tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks") + + +@tasks_bp.route("", methods=["GET"]) +async def list_tasks_route(): + q = request.args.get("q") + tag = request.args.getlist("tag") + status = request.args.get("status") + priority = request.args.get("priority") + note_id = request.args.get("note_id", type=int) + sort = request.args.get("sort", "updated_at") + order = request.args.get("order", "desc") + limit = request.args.get("limit", 50, type=int) + offset = request.args.get("offset", 0, type=int) + + tasks, total = await list_tasks( + q=q, + tags=tag or None, + status=status, + priority=priority, + note_id=note_id, + sort=sort, + order=order, + limit=limit, + offset=offset, + ) + return jsonify({"tasks": [t.to_dict() for t in tasks], "total": total}) + + +@tasks_bp.route("", methods=["POST"]) +async def create_task_route(): + data = await request.get_json() + description = data.get("description", "") + tags = extract_tags(description) + + due_date = None + if data.get("due_date"): + due_date = date.fromisoformat(data["due_date"]) + + status = TaskStatus(data["status"]) if "status" in data else TaskStatus.todo + priority = ( + TaskPriority(data["priority"]) if "priority" in data else TaskPriority.none + ) + + task = await create_task( + title=data.get("title", ""), + description=description, + status=status, + priority=priority, + due_date=due_date, + tags=tags, + ) + return jsonify(task.to_dict()), 201 + + +@tasks_bp.route("/", methods=["GET"]) +async def get_task_route(task_id: int): + task = await get_task(task_id) + if task is None: + return jsonify({"error": "Task not found"}), 404 + return jsonify(task.to_dict()) + + +@tasks_bp.route("/", methods=["PUT"]) +async def update_task_route(task_id: int): + data = await request.get_json() + fields = {} + for key in ("title", "description", "status", "priority"): + if key in data: + fields[key] = data[key] + + if "due_date" in data: + fields["due_date"] = ( + date.fromisoformat(data["due_date"]) if data["due_date"] else None + ) + + if "description" in fields: + fields["tags"] = extract_tags(fields["description"]) + + task = await update_task(task_id, **fields) + if task is None: + return jsonify({"error": "Task not found"}), 404 + return jsonify(task.to_dict()) + + +@tasks_bp.route("//status", methods=["PATCH"]) +async def patch_task_status(task_id: int): + data = await request.get_json() + status_val = data.get("status") + if not status_val: + return jsonify({"error": "status is required"}), 400 + + try: + TaskStatus(status_val) + except ValueError: + return jsonify({"error": f"Invalid status: {status_val}"}), 400 + + task = await update_task(task_id, status=status_val) + if task is None: + return jsonify({"error": "Task not found"}), 404 + return jsonify(task.to_dict()) + + +@tasks_bp.route("/", methods=["DELETE"]) +async def delete_task_route(task_id: int): + deleted = await delete_task(task_id) + if not deleted: + return jsonify({"error": "Task not found"}), 404 + return "", 204 diff --git a/src/fabledassistant/services/__init__.py b/src/fabledassistant/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py new file mode 100644 index 0000000..a9789d3 --- /dev/null +++ b/src/fabledassistant/services/notes.py @@ -0,0 +1,215 @@ +from datetime import datetime, timezone + +from sqlalchemy import func, or_, select, text + +from fabledassistant.models import async_session +from fabledassistant.models.note import Note +from fabledassistant.models.task import Task + + +async def create_note( + title: str = "", + body: str = "", + tags: list[str] | None = None, + parent_id: int | None = None, +) -> Note: + async with async_session() as session: + note = Note( + title=title, + body=body, + tags=tags or [], + parent_id=parent_id, + ) + session.add(note) + await session.commit() + await session.refresh(note) + return note + + +async def get_note(note_id: int) -> Note | None: + async with async_session() as session: + return await session.get(Note, note_id) + + +async def list_notes( + q: str | None = None, + tags: list[str] | None = None, + sort: str = "updated_at", + order: str = "desc", + limit: int = 50, + offset: int = 0, +) -> tuple[list[Note], int]: + async with async_session() as session: + query = select(Note) + count_query = select(func.count(Note.id)) + + if q: + escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + pattern = f"%{escaped_q}%" + filter_ = or_(Note.title.ilike(pattern), Note.body.ilike(pattern)) + query = query.where(filter_) + count_query = count_query.where(filter_) + + if tags: + for i, tag in enumerate(tags): + param_tag = f"tag_{i}" + param_prefix = f"tag_prefix_{i}" + tag_filter = text( + f"EXISTS (SELECT 1 FROM unnest(notes.tags) AS t" + f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})" + ).bindparams(**{param_tag: tag, param_prefix: tag + "/%"}) + query = query.where(tag_filter) + count_query = count_query.where(tag_filter) + + sort_col = getattr(Note, sort, Note.updated_at) + if order == "asc": + query = query.order_by(sort_col.asc()) + else: + query = query.order_by(sort_col.desc()) + + query = query.limit(limit).offset(offset) + + total = await session.scalar(count_query) or 0 + result = await session.execute(query) + notes = list(result.scalars().all()) + return notes, total + + +async def get_note_by_title(title: str) -> Note | None: + async with async_session() as session: + result = await session.execute( + select(Note).where(func.lower(Note.title) == func.lower(title.strip())).limit(1) + ) + return result.scalars().first() + + +async def get_or_create_note_by_title(title: str) -> Note: + title = title.strip() + note = await get_note_by_title(title) + if note: + return note + return await create_note(title=title) + + +async def update_note( + note_id: int, _skip_cascade: bool = False, **fields: object +) -> Note | None: + async with async_session() as session: + note = await session.get(Note, note_id) + if note is None: + return None + for key, value in fields.items(): + if hasattr(note, key): + setattr(note, key, value) + note.updated_at = datetime.now(timezone.utc) + + # Sync title to companion task + if not _skip_cascade and "title" in fields: + result = await session.execute( + select(Task).where(Task.note_id == note_id).limit(1) + ) + companion_task = result.scalars().first() + if companion_task: + companion_task.title = note.title + companion_task.updated_at = datetime.now(timezone.utc) + + await session.commit() + await session.refresh(note) + return note + + +async def delete_note(note_id: int, _skip_cascade: bool = False) -> bool: + async with async_session() as session: + note = await session.get(Note, note_id) + if note is None: + return False + # Cascade delete companion task + if not _skip_cascade: + result = await session.execute( + select(Task).where(Task.note_id == note_id) + ) + for companion_task in result.scalars().all(): + companion_task.note_id = None # unlink to avoid FK issues + await session.delete(companion_task) + await session.delete(note) + await session.commit() + return True + + +async def get_all_tags(q: str | None = None) -> list[str]: + async with async_session() as session: + all_tags: set[str] = set() + + for Model in (Note, Task): + result = await session.execute(select(Model)) + for obj in result.scalars(): + if obj.tags: + all_tags.update(obj.tags) + + if q: + q_lower = q.lower() + all_tags = {t for t in all_tags if q_lower in t.lower()} + + return sorted(all_tags) + + +async def convert_note_to_task(note_id: int) -> Task: + from fabledassistant.services.tasks import create_task + + note = await get_note(note_id) + if note is None: + raise ValueError("Note not found") + + # Create a new task (this auto-creates a companion note) + task = await create_task(title=note.title, tags=list(note.tags)) + + # Copy the original note's body to the companion note + if task.note_id: + await update_note(task.note_id, _skip_cascade=True, body=note.body, tags=list(note.tags)) + + # Delete the original note (skip cascade since we're managing this) + await delete_note(note_id, _skip_cascade=True) + + return task + + +async def get_backlinks(note_id: int) -> list[dict]: + note = await get_note(note_id) + if note is None: + return [] + + title = note.title + if not title: + return [] + + async with async_session() as session: + # Escape SQL LIKE wildcards in title + escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + # Search for [[Title]] or [[Title|...]] in note bodies + pattern = f"%[[{escaped}]]%" + pattern_alias = f"%[[{escaped}|%" + + # Find notes with backlinks (exclude this note itself) + note_results = await session.execute( + select(Note.id, Note.title).where( + Note.id != note_id, + or_(Note.body.like(pattern), Note.body.like(pattern_alias)), + ) + ) + backlinks: list[dict] = [] + for row in note_results.fetchall(): + backlinks.append({"type": "note", "id": row[0], "title": row[1]}) + + # Find tasks with backlinks + task_results = await session.execute( + select(Task.id, Task.title).where( + or_( + Task.description.like(pattern), + Task.description.like(pattern_alias), + ) + ) + ) + for row in task_results.fetchall(): + backlinks.append({"type": "task", "id": row[0], "title": row[1]}) + + return backlinks diff --git a/src/fabledassistant/services/tasks.py b/src/fabledassistant/services/tasks.py new file mode 100644 index 0000000..c7909f5 --- /dev/null +++ b/src/fabledassistant/services/tasks.py @@ -0,0 +1,145 @@ +from datetime import datetime, timezone + +from sqlalchemy import func, or_, select, text + +from fabledassistant.models import async_session +from fabledassistant.models.note import Note +from fabledassistant.models.task import Task, TaskPriority, TaskStatus + + +async def create_task( + title: str = "", + description: str = "", + status: TaskStatus = TaskStatus.todo, + priority: TaskPriority = TaskPriority.none, + due_date=None, + tags: list[str] | None = None, +) -> Task: + async with async_session() as session: + # Auto-create companion note + companion = Note(title=title, body="", tags=tags or []) + session.add(companion) + await session.flush() + + task = Task( + title=title, + description=description, + status=status, + priority=priority, + due_date=due_date, + note_id=companion.id, + tags=tags or [], + ) + session.add(task) + await session.commit() + await session.refresh(task) + return task + + +async def get_task(task_id: int) -> Task | None: + async with async_session() as session: + return await session.get(Task, task_id) + + +async def list_tasks( + q: str | None = None, + tags: list[str] | None = None, + status: str | None = None, + priority: str | None = None, + note_id: int | None = None, + sort: str = "updated_at", + order: str = "desc", + limit: int = 50, + offset: int = 0, +) -> tuple[list[Task], int]: + async with async_session() as session: + query = select(Task) + count_query = select(func.count(Task.id)) + + if q: + escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + pattern = f"%{escaped_q}%" + filter_ = or_(Task.title.ilike(pattern), Task.description.ilike(pattern)) + query = query.where(filter_) + count_query = count_query.where(filter_) + + if tags: + for i, tag in enumerate(tags): + param_tag = f"tag_{i}" + param_prefix = f"tag_prefix_{i}" + tag_filter = text( + f"EXISTS (SELECT 1 FROM unnest(tasks.tags) AS t" + f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})" + ).bindparams(**{param_tag: tag, param_prefix: tag + "/%"}) + query = query.where(tag_filter) + count_query = count_query.where(tag_filter) + + if status: + query = query.where(Task.status == TaskStatus(status)) + count_query = count_query.where(Task.status == TaskStatus(status)) + + if priority: + query = query.where(Task.priority == TaskPriority(priority)) + count_query = count_query.where(Task.priority == TaskPriority(priority)) + + if note_id is not None: + query = query.where(Task.note_id == note_id) + count_query = count_query.where(Task.note_id == note_id) + + sort_col = getattr(Task, sort, Task.updated_at) + if order == "asc": + query = query.order_by(sort_col.asc()) + else: + query = query.order_by(sort_col.desc()) + + query = query.limit(limit).offset(offset) + + total = await session.scalar(count_query) or 0 + result = await session.execute(query) + tasks = list(result.scalars().all()) + return tasks, total + + +async def update_task( + task_id: int, _skip_cascade: bool = False, **fields: object +) -> Task | None: + async with async_session() as session: + task = await session.get(Task, task_id) + if task is None: + return None + for key, value in fields.items(): + if not hasattr(task, key): + continue + if key == "status" and isinstance(value, str): + value = TaskStatus(value) + elif key == "priority" and isinstance(value, str): + value = TaskPriority(value) + setattr(task, key, value) + task.updated_at = datetime.now(timezone.utc) + + # Sync title to companion note + if not _skip_cascade and "title" in fields and task.note_id: + companion = await session.get(Note, task.note_id) + if companion: + companion.title = task.title + companion.updated_at = datetime.now(timezone.utc) + + await session.commit() + await session.refresh(task) + return task + + +async def delete_task(task_id: int, _skip_cascade: bool = False) -> bool: + async with async_session() as session: + task = await session.get(Task, task_id) + if task is None: + return False + companion_note_id = task.note_id + await session.delete(task) + # Cascade delete companion note + if not _skip_cascade and companion_note_id: + companion = await session.get(Note, companion_note_id) + if companion: + await session.delete(companion) + await session.commit() + return True diff --git a/src/fabledassistant/static/.gitkeep b/src/fabledassistant/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/fabledassistant/utils/__init__.py b/src/fabledassistant/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fabledassistant/utils/tags.py b/src/fabledassistant/utils/tags.py new file mode 100644 index 0000000..a436f75 --- /dev/null +++ b/src/fabledassistant/utils/tags.py @@ -0,0 +1,11 @@ +import re + +_CODE_FENCE_RE = re.compile(r"```[\s\S]*?```|`[^`\n]+`") +_TAG_RE = re.compile(r"(? list[str]: + """Extract #tags from body text, ignoring code blocks.""" + cleaned = _CODE_FENCE_RE.sub("", body) + tags = _TAG_RE.findall(cleaned) + return sorted(set(tags)) diff --git a/summary.md b/summary.md new file mode 100644 index 0000000..c8d92dc --- /dev/null +++ b/summary.md @@ -0,0 +1,270 @@ +# Fabled Assistant - Project Context + +> **Purpose:** This file is the canonical reference for re-initializing Claude Code +> context on this project. It should be updated after every significant change. + +## Last Updated +2026-02-08 — Phase 3 complete (Tasks CRUD + Wikilinks) + +## Project Overview +Fabled Assistant is a self-hosted note-taking and task-tracking application with +integrated LLM capabilities. It is designed to run on container infrastructure +(Docker Swarm) and connect to Ollama or any self-hostable LLM-compatible system +for AI-assisted features. + +## Core Architecture + +### Stack +| Layer | Technology | Notes | +|-------------|-----------|-------| +| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API | +| Backend/API | Quart (Python 3.12) | Async framework; serves both API and built frontend static files | +| LLM | Ollama | Or any OpenAI-compatible self-hosted LLM API | +| Database | PostgreSQL 16 | asyncpg driver, SQLAlchemy 2.0 async ORM, Alembic migrations | +| Deployment | Docker Compose | Single-container app + separate DB + LLM service | + +### Key Design Decisions +- **Single container for frontend + API:** Quart serves the Vue.js production + build as static files and exposes the REST API under `/api/`. +- **Quart chosen for familiarity:** The maintainer (bvandeusen) knows Quart well. +- **LLM integration is a separate service:** The app communicates with Ollama (or + compatible) over HTTP. +- **Inline tag extraction:** Tags are extracted from note/task body text using + `#tag` syntax (Obsidian-style), not manually entered. Backend is source of truth + for tag extraction. +- **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering + by `project` matches both `project` and `project/*` children via SQL `unnest` + + `LIKE` prefix. +- **Dark-first theming:** CSS custom properties on `:root` (light) and + `[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark. +- **Task-Note linking:** Tasks have an optional `note_id` FK (one task → one note). + Notes display their linked tasks in a "Linked Tasks" section. +- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown + bodies resolve to notes by exact title match via `/api/notes/by-title`. + +### High-Level Component Diagram +``` +┌─────────────────────────────────────────────┐ +│ Docker Compose │ +│ │ +│ ┌──────────────────────┐ ┌────────────┐ │ +│ │ fabledassistant │ │ ollama │ │ +│ │ ┌────────────────┐ │ │ │ │ +│ │ │ Quart Server │ │ │ LLM API │ │ +│ │ │ ┌──────────┐ │ │ │ │ │ +│ │ │ │ Vue SPA │ │ │ └────────────┘ │ +│ │ │ │ (static) │ │ │ ▲ │ +│ │ │ └──────────┘ │ │ │ │ +│ │ │ ┌──────────┐ │ │ HTTP/REST │ +│ │ │ │ /api/* │──┼──┼─────────┘ │ +│ │ │ └──────────┘ │ │ │ +│ │ │ │ │ │ ┌────────────┐ │ +│ │ │ ▼ │ │ │ PostgreSQL │ │ +│ │ │ ┌──────────┐ │ │ │ 16 │ │ +│ │ │ │ asyncpg │──┼──┼──▶ │ │ +│ │ │ └──────────┘ │ │ └────────────┘ │ +│ │ └────────────────┘ │ │ +│ └──────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +## Data Model + +### Notes (implemented) +- `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]), + `parent_id` (nullable FK to self), `created_at`, `updated_at` +- Tags are auto-extracted from body text on create/update via `#tag` regex +- Supports hierarchical organization via `parent_id` +- Lookup by exact title via `get_note_by_title()` for wikilink resolution + +### Tasks (implemented) +- `id` (int PK), `title` (str), `description` (markdown str), + `status` (enum: todo/in_progress/done), `priority` (enum: none/low/medium/high), + `due_date` (date, nullable), `note_id` (FK to notes, nullable, SET NULL), + `tags` (ARRAY[str]), `created_at`, `updated_at` +- Tags auto-extracted from description on create/update +- Status enum with quick-toggle cycling: todo → in_progress → done → todo +- Indexes: GIN on tags, B-tree on note_id, B-tree on status + +### LLM Interactions (Phase 4) +- Summarize notes, generate task breakdowns, search/query across notes, + chat-style assistant within the app + +## Project Structure (Current) +``` +fabledassistant/ +├── summary.md # This file — canonical project context +├── pyproject.toml # Python project config +├── Dockerfile # Multi-stage build (Node → Python) +├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama) +├── alembic/ # DB migrations +│ ├── alembic.ini +│ ├── env.py # Async migration runner +│ └── versions/ +│ └── 0002_create_tasks_table.py # Tasks table + enums migration +├── src/ +│ └── fabledassistant/ +│ ├── __init__.py +│ ├── app.py # Quart app factory, serves SPA + registers blueprints +│ ├── config.py # Config from env vars +│ ├── models/ +│ │ ├── __init__.py # async_session factory, Base, imports Note + Task +│ │ ├── note.py # Note model (id, title, body, tags[], parent_id, timestamps) +│ │ └── task.py # Task model (id, title, description, status, priority, due_date, note_id, tags, timestamps) +│ ├── routes/ +│ │ ├── __init__.py +│ │ ├── api.py # /api blueprint with /health endpoint +│ │ ├── notes.py # /api/notes CRUD + /by-title endpoint +│ │ └── tasks.py # /api/tasks CRUD + PATCH status +│ ├── services/ +│ │ ├── notes.py # Business logic: CRUD, hierarchical tag filter, get_note_by_title +│ │ └── tasks.py # Task CRUD: filter by status/priority/note_id/tags/search +│ ├── utils/ +│ │ ├── __init__.py +│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences +│ └── static/ # Vue production build (generated by Dockerfile) +└── frontend/ + ├── package.json # deps: vue, pinia, vue-router, marked, dompurify + ├── vite.config.ts + ├── tsconfig.json + ├── src/ + │ ├── App.vue # Shell: AppHeader + router-view + ToastNotification + │ ├── main.ts # App init, imports theme.css + │ ├── assets/ + │ │ └── theme.css # CSS custom properties: light/dark themes, body reset + │ ├── api/ + │ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete helpers + │ ├── composables/ + │ │ └── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme + │ ├── stores/ + │ │ ├── notes.ts # Notes state: CRUD + tag filter, pagination, sort, search + │ │ ├── tasks.ts # Tasks state: CRUD + status/priority filter, patchStatus + │ │ └── toast.ts # Toast notification state, 3s auto-dismiss + │ ├── types/ + │ │ ├── note.ts # Note, NoteListResponse interfaces + │ │ └── task.ts # Task, TaskStatus, TaskPriority, TaskListResponse + │ ├── utils/ + │ │ └── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks() + │ ├── views/ + │ │ ├── HomeView.vue # Landing page with health status + links to Notes/Tasks + │ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination + │ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts + │ │ ├── NoteViewerView.vue # Markdown render: DOMPurify, inline tags, wikilinks, linked tasks + │ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle + │ │ ├── TaskEditorView.vue # Create/edit task: all fields, note-link autocomplete, Ctrl+S, dirty guard + │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, linked note, wikilinks + │ ├── components/ + │ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle + │ │ ├── NoteCard.vue # Card with TagPill, tag-click emit + │ │ ├── TaskCard.vue # Card with StatusBadge (clickable), PriorityBadge, due date, tags + │ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling + │ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none") + │ │ ├── SearchBar.vue # Debounced search input + │ │ ├── TagPill.vue # Clickable/dismissible tag pill + │ │ ├── PaginationBar.vue # Prev/next + page numbers + │ │ └── ToastNotification.vue # Fixed-position toast container + │ └── router/ + │ └── index.ts # Routes: /, /notes/*, /tasks/* + └── public/ +``` + +## API Endpoints (Current) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/health` | Health check | +| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`) | +| POST | `/api/notes` | Create note (body: `{title, body}` — tags auto-extracted) | +| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (for wikilinks) | +| GET | `/api/notes/:id` | Get single note | +| PUT | `/api/notes/:id` | Update note (body: `{title?, body?}` — tags re-extracted if body changes) | +| DELETE | `/api/notes/:id` | Delete note | +| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `note_id`, `sort`, `order`, `limit`, `offset`) | +| POST | `/api/tasks` | Create task (body: `{title, description, status?, priority?, due_date?, note_id?}`) | +| GET | `/api/tasks/:id` | Get single task | +| PUT | `/api/tasks/:id` | Update task | +| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) | +| DELETE | `/api/tasks/:id` | Delete task | + +## Phased Roadmap + +### Phase 1 — Skeleton & Dev Environment ✓ +- [x] Initialize Python project (pyproject.toml, Quart app scaffold) +- [x] Initialize Vue.js project (Vite-based, inside `frontend/`) +- [x] Set up Dockerfile (multi-stage: build Vue, serve with Quart) +- [x] Docker Compose stack with Ollama service +- [x] Quart serves Vue static build + `/api/health` endpoint +- [x] Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic) + +### Phase 2 — Notes CRUD + UX ✓ +- [x] Database model for notes (title, body, tags[], parent_id, timestamps) +- [x] REST API: create, read, update, delete, list notes with pagination +- [x] Vue views: note list, note editor (markdown), note viewer (rendered) +- [x] Search (ILIKE on title/body) and tag filtering (hierarchical via unnest) +- [x] Inline `#tag` extraction from body text (backend regex, skips code fences) +- [x] Hierarchical tag filtering (`#project` matches `project` and `project/*`) +- [x] Dark/light theming with CSS custom properties + toggle + localStorage +- [x] App header with navigation and theme toggle +- [x] Tag pills (clickable + dismissible) on cards, viewer, and list filter bar +- [x] Pagination bar with prev/next and page numbers +- [x] Sort controls (field + asc/desc) +- [x] Toast notifications (success/error, 3s auto-dismiss) +- [x] Ctrl+S save shortcut in editor +- [x] Unsaved changes guard (route leave + beforeunload) +- [x] DOMPurify sanitization on rendered markdown +- [x] Inline tag linkification in rendered markdown (clickable `#tag` links) + +### Phase 3 — Tasks CRUD + Wikilinks ✓ +- [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums +- [x] Alembic migration for tasks table with PG enums and indexes +- [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/note_id/tags +- [x] Task-Note linking via optional `note_id` FK +- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (note-link autocomplete, Ctrl+S, dirty guard), viewer (rendered markdown) +- [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components +- [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown +- [x] Wikilink click handling resolves notes by title via `/api/notes/by-title` +- [x] "Linked Tasks" section on NoteViewerView with inline status toggling +- [x] Theme variables for status/priority/wikilink/overdue colors (light + dark) + +### Phase 4 — LLM Integration (next) +- [ ] Ollama client in the backend (async HTTP via httpx/aiohttp) +- [ ] API endpoints for LLM features (summarize note, generate tasks from note, + freeform chat) +- [ ] Vue components for LLM interactions (chat panel, summarize button, etc.) +- [ ] Configurable LLM endpoint + model selection in app settings + +### Phase 5 — Polish & Production Hardening +- [ ] Authentication (single-user or multi-user, TBD) +- [ ] Docker Swarm production stack (secrets, volumes, networking) +- [ ] Backup/restore strategy for data +- [ ] UI/UX refinements, responsive design +- [ ] Error handling, logging, monitoring + +### Future / Stretch +- WebSocket streaming for LLM responses +- Tagging/labeling system with LLM-suggested tags +- Calendar/timeline view for tasks +- Import/export (Markdown files, JSON) +- Plugin/extension system + +## Development Workflow +- All development and testing done via Docker: `docker compose up --build` +- No local dependency installation — everything containerized +- Frontend dev: Vite dev server with proxy to Quart (via Docker) +- Production build: Dockerfile multi-stage — Vite builds Vue into static files, + Quart serves them + +## Open Questions +- Authentication model: single-user (password-only) vs multi-user? +- Should LLM streaming use WebSockets from Phase 4 or defer to Phase 5? + +## Current Status +**Phase:** Phase 3 complete. Tasks CRUD with wikilinks fully implemented. +- Full notes CRUD with markdown editing, rendering, and wikilinks +- Full tasks CRUD with status/priority enums, filters, note linking +- Inline `#tag` extraction for both notes and tasks +- Obsidian-style `[[wikilinks]]` with title resolution +- StatusBadge with clickable cycling, PriorityBadge, overdue date styling +- "Linked Tasks" section on note viewer with inline status toggling +- Dark/light theme with status/priority/wikilink color variables +- Ready for Phase 4: LLM Integration