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:
@@ -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
|
||||
Reference in New Issue
Block a user