Merge tasks into notes: a task is just a note with task attributes

A task is now a note with status/priority/due_date columns set (status IS NOT NULL).
This eliminates the separate tasks table, companion note system, cascade deletes,
bidirectional title sync, and _skip_cascade flags.

Migration (0004):
- Add status, priority, due_date columns to notes table
- Migrate task data from companion notes and orphan tasks
- Drop tasks table and old enum types

Backend:
- models/note.py: Add TaskStatus/TaskPriority enums, task columns, is_task property
- models/task.py: Deleted (merged into note.py)
- models/__init__.py: Re-export enums from note.py, remove Task import
- services/notes.py: Remove companion/cascade logic, add is_task filter,
  convert_note_to_task, convert_task_to_note, simplified backlinks
- services/tasks.py: Rewritten as thin wrappers around notes service
- routes/notes.py: Add is_task filter (default false), task fields in CRUD,
  convert-to-note endpoint
- routes/tasks.py: description→body (with fallback), remove note_id filter

Frontend:
- types/note.ts: Add TaskStatus, TaskPriority, task fields to Note interface
- types/task.ts: Task is now a re-export alias for Note
- stores/notes.ts: Simplify convertToTask, add convertToNote
- stores/tasks.ts: description→body in createTask/updateTask
- TaskEditorView: description→body, remove companion note UI
- TaskViewerView: description→body, remove companion note link, add Convert to Note
- NoteViewerView: Remove companion task UI, simplify convert-to-task
- TaskCard: description→body, non-null assertions for status/priority

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 17:56:12 -05:00
parent e2338918b0
commit 807cde30be
17 changed files with 439 additions and 532 deletions
+54 -65
View File
@@ -1,10 +1,9 @@
from datetime import datetime, timezone
from datetime import date, 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
from fabledassistant.models.note import Note, TaskPriority, TaskStatus
async def create_note(
@@ -12,6 +11,9 @@ async def create_note(
body: str = "",
tags: list[str] | None = None,
parent_id: int | None = None,
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
) -> Note:
async with async_session() as session:
note = Note(
@@ -19,6 +21,9 @@ async def create_note(
body=body,
tags=tags or [],
parent_id=parent_id,
status=status,
priority=priority,
due_date=due_date,
)
session.add(note)
await session.commit()
@@ -34,6 +39,9 @@ async def get_note(note_id: int) -> Note | None:
async def list_notes(
q: str | None = None,
tags: list[str] | None = None,
is_task: bool | None = None,
status: str | None = None,
priority: str | None = None,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
@@ -43,6 +51,14 @@ async def list_notes(
query = select(Note)
count_query = select(func.count(Note.id))
# Filter by task vs note
if is_task is True:
query = query.where(Note.status.isnot(None))
count_query = count_query.where(Note.status.isnot(None))
elif is_task is False:
query = query.where(Note.status.is_(None))
count_query = count_query.where(Note.status.is_(None))
if q:
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped_q}%"
@@ -61,6 +77,14 @@ async def list_notes(
query = query.where(tag_filter)
count_query = count_query.where(tag_filter)
if status:
query = query.where(Note.status == status)
count_query = count_query.where(Note.status == status)
if priority:
query = query.where(Note.priority == priority)
count_query = count_query.where(Note.priority == priority)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
@@ -91,46 +115,30 @@ async def get_or_create_note_by_title(title: str) -> Note:
return await create_note(title=title)
async def update_note(
note_id: int, _skip_cascade: bool = False, **fields: object
) -> Note | None:
async def update_note(note_id: int, **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)
if not hasattr(note, key):
continue
if key == "status" and isinstance(value, str):
value = TaskStatus(value).value
elif key == "priority" and isinstance(value, str):
value = TaskPriority(value).value
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 def delete_note(note_id: int) -> 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
@@ -140,11 +148,10 @@ 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)
result = await session.execute(select(Note))
for obj in result.scalars():
if obj.tags:
all_tags.update(obj.tags)
if q:
q_lower = q.lower()
@@ -153,24 +160,20 @@ async def get_all_tags(q: str | None = None) -> list[str]:
return sorted(all_tags)
async def convert_note_to_task(note_id: int) -> Task:
from fabledassistant.services.tasks import create_task
async def convert_note_to_task(note_id: int) -> Note:
note = await get_note(note_id)
if note is None:
raise ValueError("Note not found")
updated = await update_note(note_id, status=TaskStatus.todo.value, priority=TaskPriority.none.value)
return updated
# 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 convert_task_to_note(note_id: int) -> Note:
note = await get_note(note_id)
if note is None:
raise ValueError("Note not found")
updated = await update_note(note_id, status=None, priority=None, due_date=None)
return updated
async def get_backlinks(note_id: int) -> list[dict]:
@@ -183,33 +186,19 @@ async def get_backlinks(note_id: int) -> list[dict]:
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(
results = await session.execute(
select(Note.id, Note.title, Note.status).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]})
for row in results.fetchall():
link_type = "task" if row[2] is not None else "note"
backlinks.append({"type": link_type, "id": row[0], "title": row[1]})
return backlinks