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 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 23:35:44 -05:00
commit 22a3a3c1d1
71 changed files with 7173 additions and 0 deletions
View File
+57
View File
@@ -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
+10
View File
@@ -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")
+15
View File
@@ -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
+40
View File
@@ -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(),
}
+70
View File
@@ -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(),
}
+8
View File
@@ -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"})
+120
View File
@@ -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("/<int:note_id>", 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("/<int:note_id>", 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("/<int:note_id>", 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("/<int:note_id>/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("/<int:note_id>/backlinks", methods=["GET"])
async def get_backlinks_route(note_id: int):
links = await get_backlinks(note_id)
return jsonify({"backlinks": links})
+123
View File
@@ -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("/<int:task_id>", 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("/<int:task_id>", 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("/<int:task_id>/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("/<int:task_id>", 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
+215
View File
@@ -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
+145
View File
@@ -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
View File
+11
View File
@@ -0,0 +1,11 @@
import re
_CODE_FENCE_RE = re.compile(r"```[\s\S]*?```|`[^`\n]+`")
_TAG_RE = re.compile(r"(?<!\w)#([\w]+(?:/[\w]+)*)")
def extract_tags(body: str) -> 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))