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