diff --git a/Dockerfile b/Dockerfile index 6e343d4..9429f47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,4 @@ COPY alembic/ alembic/ ENV PYTHONPATH=/app/src EXPOSE 5000 -CMD ["hypercorn", "fabledassistant.app:create_app()", "--bind", "0.0.0.0:5000"] +CMD ["sh", "-c", "(alembic stamp --purge base 2>/dev/null || true) && alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000"] diff --git a/alembic/versions/0001_create_notes_table.py b/alembic/versions/0001_create_notes_table.py new file mode 100644 index 0000000..e32350f --- /dev/null +++ b/alembic/versions/0001_create_notes_table.py @@ -0,0 +1,33 @@ +"""create notes table + +Revision ID: 0001 +Revises: +Create Date: 2025-01-01 00:00:00.000000 +""" + +from alembic import op + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS notes ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + tags TEXT[] NOT NULL DEFAULT '{}', + parent_id INTEGER REFERENCES notes(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_notes_tags ON notes USING gin (tags)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_notes_tags") + op.execute("DROP TABLE IF EXISTS notes") diff --git a/alembic/versions/0002_create_tasks_table.py b/alembic/versions/0002_create_tasks_table.py index 32352df..eab7c95 100644 --- a/alembic/versions/0002_create_tasks_table.py +++ b/alembic/versions/0002_create_tasks_table.py @@ -1,76 +1,54 @@ """create tasks table Revision ID: 0002 -Revises: +Revises: 0001 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 +down_revision = "0001" 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"]) + op.execute(""" + DO $$ BEGIN + CREATE TYPE task_status AS ENUM ('todo', 'in_progress', 'done'); + EXCEPTION WHEN duplicate_object THEN null; + END $$ + """) + op.execute(""" + DO $$ BEGIN + CREATE TYPE task_priority AS ENUM ('none', 'low', 'medium', 'high'); + EXCEPTION WHEN duplicate_object THEN null; + END $$ + """) + op.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + status task_status NOT NULL DEFAULT 'todo', + priority task_priority NOT NULL DEFAULT 'none', + due_date DATE, + note_id INTEGER REFERENCES notes(id) ON DELETE SET NULL, + tags TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_tasks_tags ON tasks USING gin (tags)") + op.execute("CREATE INDEX IF NOT EXISTS ix_tasks_note_id ON tasks (note_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON 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) + op.execute("DROP INDEX IF EXISTS ix_tasks_status") + op.execute("DROP INDEX IF EXISTS ix_tasks_note_id") + op.execute("DROP INDEX IF EXISTS ix_tasks_tags") + op.execute("DROP TABLE IF EXISTS tasks") + op.execute("DROP TYPE IF EXISTS task_priority") + op.execute("DROP TYPE IF EXISTS task_status") diff --git a/summary.md b/summary.md index c8d92dc..d74e031 100644 --- a/summary.md +++ b/summary.md @@ -1,10 +1,10 @@ # 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. +> context on this project. **Update this file after every change session.** ## Last Updated -2026-02-08 — Phase 3 complete (Tasks CRUD + Wikilinks) +2026-02-09 — Phase 3.5 complete (Task-Note companions, wikilink auto-create, backlinks, bug fixes, Alembic migration infrastructure) ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with @@ -27,6 +27,10 @@ for AI-assisted features. - **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. +- **SPA routing via 404 handler:** `app.py` uses `@app.errorhandler(404)` (not a + catch-all route) to serve static files or fall back to `index.html`. API routes + (`/api/*`) get JSON 404 responses. This avoids a catch-all `/` route + intercepting API GETs. - **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 @@ -37,10 +41,18 @@ for AI-assisted features. `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. +- **Task-Note companion link:** Every task automatically gets a companion note + (created in `create_task()`). Title changes sync bidirectionally. Deleting either + cascades to the other. `_skip_cascade` flag prevents infinite loops. - **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown - bodies resolve to notes by exact title match via `/api/notes/by-title`. + bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create + missing notes. +- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies and task + descriptions for `[[Title]]` patterns referencing the given note. +- **Idempotent raw SQL migrations:** All Alembic migrations use raw SQL with + `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN + CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and + fresh database creation. ### High-Level Component Diagram ``` @@ -76,14 +88,17 @@ for AI-assisted features. - 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 +- Auto-create via `get_or_create_note_by_title()` for wikilink clicks ### 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), + `due_date` (date, nullable), `note_id` (FK to notes, auto-created companion), `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 +- Companion note auto-created on task creation, title synced bidirectionally +- Deleting a task deletes its companion note (and vice versa) - Indexes: GIN on tags, B-tree on note_id, B-tree on status ### LLM Interactions (Phase 4) @@ -97,15 +112,17 @@ fabledassistant/ ├── pyproject.toml # Python project config ├── Dockerfile # Multi-stage build (Node → Python) ├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama) -├── alembic/ # DB migrations -│ ├── alembic.ini +├── alembic.ini # Alembic config (prepend_sys_path = src) +├── alembic/ │ ├── env.py # Async migration runner │ └── versions/ -│ └── 0002_create_tasks_table.py # Tasks table + enums migration +│ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent) +│ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent) +│ └── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks ├── src/ │ └── fabledassistant/ │ ├── __init__.py -│ ├── app.py # Quart app factory, serves SPA + registers blueprints +│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API │ ├── config.py # Config from env vars │ ├── models/ │ │ ├── __init__.py # async_session factory, Base, imports Note + Task @@ -114,11 +131,11 @@ fabledassistant/ │ ├── routes/ │ │ ├── __init__.py │ │ ├── api.py # /api blueprint with /health endpoint -│ │ ├── notes.py # /api/notes CRUD + /by-title endpoint +│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /backlinks │ │ └── 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 +│ │ ├── notes.py # CRUD, tag filter, get_or_create_by_title, convert_note_to_task, get_backlinks, cascade delete/sync +│ │ └── tasks.py # CRUD with auto companion note, cascade delete/sync, _skip_cascade flag │ ├── utils/ │ │ ├── __init__.py │ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences @@ -135,30 +152,33 @@ fabledassistant/ │ ├── api/ │ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete helpers │ ├── composables/ - │ │ └── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme + │ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme + │ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search │ ├── stores/ - │ │ ├── notes.ts # Notes state: CRUD + tag filter, pagination, sort, search - │ │ ├── tasks.ts # Tasks state: CRUD + status/priority filter, patchStatus + │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, fetchBacklinks, fetchAllTags + │ │ ├── tasks.ts # 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() + │ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks() + │ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards) │ ├── views/ - │ │ ├── HomeView.vue # Landing page with health status + links to Notes/Tasks + │ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling) │ │ ├── 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 + │ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete + │ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, companion task link, convert-to-task, backlinks │ │ ├── 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 + │ │ ├── TaskEditorView.vue # Create/edit task: fields, companion note link, Ctrl+S, dirty guard, autocomplete + │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, companion note link, backlinks │ ├── 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 + │ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit + │ │ ├── TaskCard.vue # Card with rendered preview, StatusBadge (clickable), PriorityBadge, due date, tags │ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling │ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none") + │ │ ├── MarkdownToolbar.vue # Bold/italic/link/list/heading toolbar for editor │ │ ├── SearchBar.vue # Debounced search input │ │ ├── TagPill.vue # Clickable/dismissible tag pill │ │ ├── PaginationBar.vue # Prev/next + page numbers @@ -175,16 +195,100 @@ fabledassistant/ | 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/tags` | List all tags from notes + tasks (param: `q` for filter) | +| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) | +| POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) | | 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 | +| DELETE | `/api/notes/:id` | Delete note (cascades to companion task) | +| POST | `/api/notes/:id/convert-to-task` | Convert note into a task (deletes original note) | +| GET | `/api/notes/:id/backlinks` | List notes/tasks that reference this note via wikilinks | | 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?}`) | +| POST | `/api/tasks` | Create task (body: `{title, description, status?, priority?, due_date?}` — companion note auto-created) | | GET | `/api/tasks/:id` | Get single task | -| PUT | `/api/tasks/:id` | Update task | +| PUT | `/api/tasks/:id` | Update task (title syncs to companion note) | | PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) | -| DELETE | `/api/tasks/:id` | Delete task | +| DELETE | `/api/tasks/:id` | Delete task (cascades to companion note) | + +## Alembic Migrations + +### Overview +Migrations use raw SQL for full idempotency — safe to run on a fresh database +or re-run on an existing one. The Dockerfile runs migrations automatically on +container startup. + +### Migration Chain +``` +0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py +``` + +### How Migrations Run +The Dockerfile CMD runs: +```sh +(alembic stamp --purge base 2>/dev/null || true) && alembic upgrade head && hypercorn ... +``` +- `alembic stamp --purge base` clears any stale revision stamps (safe no-op if none) +- `alembic upgrade head` runs all pending migrations in order +- Migrations are idempotent so re-running is safe + +### Creating a New Migration +When adding a new migration, follow these conventions: + +1. **Create the migration file:** + ``` + alembic/versions/0004_description.py + ``` + +2. **Use raw SQL for idempotency:** + ```python + from alembic import op + + revision = "0004" + down_revision = "0003" + + def upgrade() -> None: + # For new enums: + op.execute(""" + DO $$ BEGIN + CREATE TYPE my_enum AS ENUM ('a', 'b'); + EXCEPTION WHEN duplicate_object THEN null; + END $$ + """) + # For new tables: + op.execute(""" + CREATE TABLE IF NOT EXISTS my_table (...) + """) + # For new indexes: + op.execute("CREATE INDEX IF NOT EXISTS ix_name ON table (col)") + # For adding columns: + op.execute(""" + DO $$ BEGIN + ALTER TABLE my_table ADD COLUMN new_col TEXT DEFAULT ''; + EXCEPTION WHEN duplicate_column THEN null; + END $$ + """) + + def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_name") + op.execute("DROP TABLE IF EXISTS my_table") + op.execute("DROP TYPE IF EXISTS my_enum") + ``` + +3. **Rebuild and test:** + ```bash + docker compose up --build + ``` + To test from a completely fresh database: + ```bash + docker compose down -v && docker compose up --build + ``` + +### Important Notes +- Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system + can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run +- Always write raw SQL with PostgreSQL idempotency guards +- Set `down_revision` to chain from the previous migration's `revision` +- Data migrations (like 0003) should use `op.get_bind()` + `sa.text()` for queries ## Phased Roadmap @@ -226,6 +330,22 @@ fabledassistant/ - [x] "Linked Tasks" section on NoteViewerView with inline status toggling - [x] Theme variables for status/priority/wikilink/overdue colors (light + dark) +### Phase 3.5 — Note-Task Integration + Bug Fixes ✓ +- [x] **Task-Note companion link:** Every task auto-creates a companion note on creation +- [x] **Bidirectional title sync:** Renaming task syncs to companion note and vice versa +- [x] **Cascade delete:** Deleting a task deletes its companion note (and vice versa) +- [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist +- [x] **Convert note to task:** Button on note viewer, creates task with companion note, deletes original +- [x] **Backlinks system:** "What links here" section on note and task viewers +- [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped) +- [x] **Tag autocomplete Tab cycling:** Tab cycles through suggestions, single match accepts immediately +- [x] **HomeView error isolation:** Notes and tasks load independently (one failure doesn't break both) +- [x] **SPA routing fix:** Replaced catch-all route with 404 error handler to prevent API interception +- [x] **500 error handler:** JSON error responses for API routes, traceback logging +- [x] **Idempotent migrations:** All migrations rewritten to raw SQL with IF NOT EXISTS guards +- [x] **Auto-migration on startup:** Dockerfile runs `alembic upgrade head` before starting app +- [x] **Data migration 0003:** Creates companion notes for pre-existing tasks + ### 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, @@ -253,18 +373,24 @@ fabledassistant/ - 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 +- To reset database: `docker compose down -v && docker compose up --build` ## 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. +**Phase:** Phase 3.5 complete. Note-task companion system, wikilink auto-create, +backlinks, and bug fixes 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 +- Full tasks CRUD with status/priority enums, filters, companion note linking +- Automatic companion note creation for every task +- Bidirectional title sync and cascade delete between tasks and notes +- Convert note to task functionality +- Backlinks system showing "what links here" for notes and tasks +- Wikilink clicks auto-create missing notes +- Rendered markdown previews on list cards +- Tag autocomplete with Tab cycling +- Idempotent raw SQL migrations with auto-run on startup - Dark/light theme with status/priority/wikilink color variables - Ready for Phase 4: LLM Integration