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
+1 -2
View File
@@ -11,5 +11,4 @@ class Base(DeclarativeBase):
pass
from fabledassistant.models.note import Note # noqa: E402, F401
from fabledassistant.models.task import Task # noqa: E402, F401
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
+32 -4
View File
@@ -1,12 +1,26 @@
from datetime import datetime, timezone
import enum
from datetime import date, datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Text
from sqlalchemy import Date, DateTime, 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 Note(Base):
__tablename__ = "notes"
@@ -15,8 +29,11 @@ class Note(Base):
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
nullable=True
)
status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -26,7 +43,14 @@ class Note(Base):
onupdate=lambda: datetime.now(timezone.utc),
)
__table_args__ = (Index("ix_notes_tags", "tags", postgresql_using="gin"),)
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
Index("ix_notes_status", "status"),
)
@property
def is_task(self) -> bool:
return self.status is not None
def to_dict(self) -> dict:
return {
@@ -35,6 +59,10 @@ class Note(Base):
"body": self.body,
"tags": self.tags or [],
"parent_id": self.parent_id,
"status": self.status,
"priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None,
"is_task": self.is_task,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
-70
View File
@@ -1,70 +0,0 @@
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(),
}
+40 -4
View File
@@ -1,7 +1,10 @@
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.services.notes import (
convert_note_to_task,
convert_task_to_note,
create_note,
delete_note,
get_all_tags,
@@ -26,8 +29,15 @@ async def list_notes_route():
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
is_task: bool | None = False
if request.args.get("all", "").lower() == "true":
is_task = None
elif request.args.get("is_task", "").lower() == "true":
is_task = True
notes, total = await list_notes(
q=q, tags=tag or None, sort=sort, order=order, limit=limit, offset=offset
q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, limit=limit, offset=offset
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
@@ -37,11 +47,22 @@ async def create_note_route():
data = await request.get_json()
body = data.get("body", "")
tags = extract_tags(body)
# Optional task fields
status = data.get("status")
priority = data.get("priority")
due_date = None
if data.get("due_date"):
due_date = date.fromisoformat(data["due_date"])
note = await create_note(
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
status=status,
priority=priority,
due_date=due_date,
)
return jsonify(note.to_dict()), 201
@@ -86,9 +107,15 @@ async def get_note_route(note_id: int):
async def update_note_route(note_id: int):
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id"):
for key in ("title", "body", "parent_id", "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 "body" in fields:
fields["tags"] = extract_tags(fields["body"])
note = await update_note(note_id, **fields)
@@ -108,8 +135,17 @@ async def delete_note_route(note_id: int):
@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
note = await convert_note_to_task(note_id)
return jsonify(note.to_dict()), 200
except ValueError as e:
return jsonify({"error": str(e)}), 404
@notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"])
async def convert_task_to_note_route(note_id: int):
try:
note = await convert_task_to_note(note_id)
return jsonify(note.to_dict()), 200
except ValueError as e:
return jsonify({"error": str(e)}), 404
+15 -11
View File
@@ -2,7 +2,7 @@ from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.models.task import TaskPriority, TaskStatus
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.services.tasks import (
create_task,
delete_task,
@@ -21,7 +21,6 @@ async def list_tasks_route():
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)
@@ -32,7 +31,6 @@ async def list_tasks_route():
tags=tag or None,
status=status,
priority=priority,
note_id=note_id,
sort=sort,
order=order,
limit=limit,
@@ -44,21 +42,21 @@ async def list_tasks_route():
@tasks_bp.route("", methods=["POST"])
async def create_task_route():
data = await request.get_json()
description = data.get("description", "")
tags = extract_tags(description)
body = data.get("body", "") or data.get("description", "")
tags = extract_tags(body)
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
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
priority = (
TaskPriority(data["priority"]) if "priority" in data else TaskPriority.none
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
)
task = await create_task(
title=data.get("title", ""),
description=description,
body=body,
status=status,
priority=priority,
due_date=due_date,
@@ -79,17 +77,23 @@ async def get_task_route(task_id: int):
async def update_task_route(task_id: int):
data = await request.get_json()
fields = {}
for key in ("title", "description", "status", "priority"):
for key in ("title", "status", "priority"):
if key in data:
fields[key] = data[key]
# Accept both "body" and "description" (prefer body)
if "body" in data:
fields["body"] = data["body"]
elif "description" in data:
fields["body"] = data["description"]
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"])
if "body" in fields:
fields["tags"] = extract_tags(fields["body"])
task = await update_task(task_id, **fields)
if task is None:
+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
+40 -122
View File
@@ -1,44 +1,35 @@
from datetime import datetime, timezone
from datetime import date
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
from fabledassistant.models.note import Note, TaskPriority, TaskStatus
from fabledassistant.services.notes import (
create_note,
delete_note,
get_note,
list_notes,
update_note,
)
async def create_task(
title: str = "",
description: str = "",
status: TaskStatus = TaskStatus.todo,
priority: TaskPriority = TaskPriority.none,
due_date=None,
body: str = "",
status: str = TaskStatus.todo.value,
priority: str = TaskPriority.none.value,
due_date: date | None = 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
) -> Note:
return await create_note(
title=title,
body=body,
tags=tags,
status=status,
priority=priority,
due_date=due_date,
)
async def get_task(task_id: int) -> Task | None:
async with async_session() as session:
return await session.get(Task, task_id)
async def get_task(task_id: int) -> Note | None:
return await get_note(task_id)
async def list_tasks(
@@ -46,100 +37,27 @@ async def list_tasks(
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
) -> tuple[list[Note], int]:
return await list_notes(
q=q,
tags=tags,
is_task=True,
status=status,
priority=priority,
sort=sort,
order=order,
limit=limit,
offset=offset,
)
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 update_task(task_id: int, **fields: object) -> Note | None:
return await update_note(task_id, **fields)
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
async def delete_task(task_id: int) -> bool:
return await delete_note(task_id)