diff --git a/alembic/versions/0004_merge_tasks_into_notes.py b/alembic/versions/0004_merge_tasks_into_notes.py new file mode 100644 index 0000000..073bb89 --- /dev/null +++ b/alembic/versions/0004_merge_tasks_into_notes.py @@ -0,0 +1,60 @@ +"""merge tasks into notes table + +Revision ID: 0004 +Revises: 0003 +Create Date: 2025-01-01 00:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add task columns to notes table + op.add_column("notes", sa.Column("status", sa.Text(), nullable=True)) + op.add_column("notes", sa.Column("priority", sa.Text(), nullable=True)) + op.add_column("notes", sa.Column("due_date", sa.Date(), nullable=True)) + op.create_index("ix_notes_status", "notes", ["status"]) + + conn = op.get_bind() + + # Migrate tasks that have companion notes: + # Copy status/priority/due_date to the companion note, + # and merge the task description into the note body if the note body is empty. + conn.execute(sa.text(""" + UPDATE notes + SET status = t.status, + priority = t.priority, + due_date = t.due_date, + body = COALESCE(NULLIF(t.description, ''), notes.body), + tags = CASE + WHEN array_length(t.tags, 1) IS NOT NULL THEN t.tags + ELSE notes.tags + END + FROM tasks t + WHERE t.note_id = notes.id + """)) + + # Handle orphan tasks (note_id IS NULL) — insert them as new notes + conn.execute(sa.text(""" + INSERT INTO notes (title, body, tags, status, priority, due_date, created_at, updated_at) + SELECT title, description, tags, status, priority, due_date, created_at, updated_at + FROM tasks + WHERE note_id IS NULL + """)) + + # Drop tasks table and its indexes + op.drop_table("tasks") + + # Drop the old enum types that are no longer used by any table + op.execute("DROP TYPE IF EXISTS task_status") + op.execute("DROP TYPE IF EXISTS task_priority") + + +def downgrade() -> None: + raise NotImplementedError("Downgrade not supported for this migration") diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index 6f12136..d1b1760 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -19,7 +19,7 @@ const statusCycle: Record = { }; function cycleStatus() { - emit("status-toggle", props.task.id, statusCycle[props.task.status]); + emit("status-toggle", props.task.id, statusCycle[props.task.status!]); } function isOverdue(): boolean { @@ -32,14 +32,14 @@ function isOverdue(): boolean {
- +

{{ task.title || "Untitled" }}

-
+
Due: {{ task.due_date }} diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 2b3993e..16074ef 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -142,17 +142,27 @@ export const useNotesStore = defineStore("notes", () => { return await apiPost("/api/notes/resolve-title", { title }); } - async function convertToTask(noteId: number) { - const task = await apiPost>( + async function convertToTask(noteId: number): Promise { + const note = await apiPost( `/api/notes/${noteId}/convert-to-task`, {} ); - // Remove the note from local state since it was converted - notes.value = notes.value.filter((n) => n.id !== noteId); + // Update local state — the note is now a task if (currentNote.value?.id === noteId) { - currentNote.value = null; + currentNote.value = note; } - return task; + return note; + } + + async function convertToNote(noteId: number): Promise { + const note = await apiPost( + `/api/notes/${noteId}/convert-to-note`, + {} + ); + if (currentNote.value?.id === noteId) { + currentNote.value = note; + } + return note; } async function fetchBacklinks( @@ -196,6 +206,7 @@ export const useNotesStore = defineStore("notes", () => { refresh, resolveTitle, convertToTask, + convertToNote, fetchBacklinks, fetchAllTags, }; diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 3a24b7a..4c922d5 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -57,7 +57,7 @@ export const useTasksStore = defineStore("tasks", () => { async function createTask(data: { title: string; - description: string; + body: string; status?: TaskStatus; priority?: TaskPriority; due_date?: string | null; @@ -68,7 +68,7 @@ export const useTasksStore = defineStore("tasks", () => { async function updateTask( id: number, data: Partial< - Pick + Pick > ): Promise { const task = await apiPut(`/api/tasks/${id}`, data); diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index 1ea8bfa..5b157af 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -1,9 +1,16 @@ +export type TaskStatus = "todo" | "in_progress" | "done"; +export type TaskPriority = "none" | "low" | "medium" | "high"; + export interface Note { id: number; title: string; body: string; tags: string[]; parent_id: number | null; + status: TaskStatus | null; + priority: TaskPriority | null; + due_date: string | null; + is_task: boolean; created_at: string; updated_at: string; } diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index ada7723..919f476 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -1,20 +1,6 @@ -export type TaskStatus = "todo" | "in_progress" | "done"; -export type TaskPriority = "none" | "low" | "medium" | "high"; - -export interface Task { - id: number; - title: string; - description: string; - status: TaskStatus; - priority: TaskPriority; - due_date: string | null; - note_id: number | null; - tags: string[]; - created_at: string; - updated_at: string; -} +export type { TaskStatus, TaskPriority, Note as Task } from "./note"; export interface TaskListResponse { - tasks: Task[]; + tasks: import("./note").Note[]; total: number; } diff --git a/frontend/src/views/NoteViewerView.vue b/frontend/src/views/NoteViewerView.vue index 1ffbbf5..84f45f7 100644 --- a/frontend/src/views/NoteViewerView.vue +++ b/frontend/src/views/NoteViewerView.vue @@ -4,25 +4,20 @@ import { useRoute, useRouter } from "vue-router"; import { useNotesStore } from "@/stores/notes"; import { renderMarkdown } from "@/utils/markdown"; import { relativeTime } from "@/composables/useRelativeTime"; -import { apiGet, apiPost } from "@/api/client"; -import type { Task, TaskListResponse, TaskStatus } from "@/types/task"; +import { apiPost } from "@/api/client"; import type { Note } from "@/types/note"; import TagPill from "@/components/TagPill.vue"; -import StatusBadge from "@/components/StatusBadge.vue"; const route = useRoute(); const router = useRouter(); const store = useNotesStore(); const bodyEl = ref(null); -const linkedTasks = ref([]); const backlinks = ref<{ type: string; id: number; title: string }[]>([]); const converting = ref(false); const noteId = computed(() => Number(route.params.id)); -const hasCompanionTask = computed(() => linkedTasks.value.length > 0); async function loadNote(id: number) { - linkedTasks.value = []; backlinks.value = []; await store.fetchNote(id); @@ -32,16 +27,10 @@ async function loadNote(id: number) { return; } - // Fetch tasks linked to this note & backlinks in parallel - const [taskData, backlinkData] = await Promise.allSettled([ - apiGet(`/api/tasks?note_id=${id}`), - store.fetchBacklinks(id), - ]); - if (taskData.status === "fulfilled") { - linkedTasks.value = taskData.value.tasks; - } - if (backlinkData.status === "fulfilled") { - backlinks.value = backlinkData.value; + try { + backlinks.value = await store.fetchBacklinks(id); + } catch { + backlinks.value = []; } } @@ -93,37 +82,14 @@ function onTagClick(tag: string) { router.push({ path: "/notes", query: { tag } }); } -const statusCycle: Record = { - todo: "in_progress", - in_progress: "done", - done: "todo", -}; - -async function toggleTaskStatus(task: Task) { - const newStatus = statusCycle[task.status]; - try { - const { apiPatch } = await import("@/api/client"); - const updated = await apiPatch( - `/api/tasks/${task.id}/status`, - { status: newStatus } - ); - const idx = linkedTasks.value.findIndex((t) => t.id === task.id); - if (idx !== -1) { - linkedTasks.value[idx] = updated; - } - } catch { - // silently fail - } -} - async function convertToTask() { if (converting.value) return; converting.value = true; try { - const task = await store.convertToTask(noteId.value); + await store.convertToTask(noteId.value); const { useToastStore } = await import("@/stores/toast"); useToastStore().show("Converted to task"); - router.push(`/tasks/${task.id}`); + router.push(`/tasks/${noteId.value}`); } catch { const { useToastStore } = await import("@/stores/toast"); useToastStore().show("Failed to convert note", "error"); @@ -146,7 +112,7 @@ async function convertToTask() { Edit
-
-

Companion Task

-
    -
  • - - - {{ task.title || "Untitled" }} - -
  • -
-
- -
- - - {{ companionNote.title || "Untitled" }} - -
-

{{ store.currentTask.title || "Untitled" }}

@@ -132,11 +145,11 @@ function onTagClick(tag: string) {

- +
-
- - View Note - -
@@ -201,6 +206,25 @@ function onTagClick(tag: string) { text-decoration: none; color: var(--color-primary); } +.btn-convert { + margin-left: auto; + padding: 0.3rem 0.75rem; + background: var(--color-bg-secondary); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} +.btn-convert:hover { + background: var(--color-primary); + color: #fff; + border-color: var(--color-primary); +} +.btn-convert:disabled { + opacity: 0.6; + cursor: default; +} .meta { font-size: 0.85rem; color: var(--color-text-muted); @@ -220,18 +244,6 @@ function onTagClick(tag: string) { color: var(--color-overdue); font-weight: 600; } -.linked-note { - font-size: 0.9rem; - color: var(--color-text-secondary); - margin-bottom: 0.75rem; -} -.note-link { - color: var(--color-primary); - text-decoration: none; -} -.note-link:hover { - text-decoration: underline; -} .tags { display: flex; gap: 0.5rem; diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index 7bdbf63..cf98cbe 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -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 diff --git a/src/fabledassistant/models/note.py b/src/fabledassistant/models/note.py index bb45752..3e870f4 100644 --- a/src/fabledassistant/models/note.py +++ b/src/fabledassistant/models/note.py @@ -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(), } diff --git a/src/fabledassistant/models/task.py b/src/fabledassistant/models/task.py deleted file mode 100644 index 2b35d8f..0000000 --- a/src/fabledassistant/models/task.py +++ /dev/null @@ -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(), - } diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py index 77c30a1..a2c0c77 100644 --- a/src/fabledassistant/routes/notes.py +++ b/src/fabledassistant/routes/notes.py @@ -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("//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("//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 diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py index c7884e6..ea87f73 100644 --- a/src/fabledassistant/routes/tasks.py +++ b/src/fabledassistant/routes/tasks.py @@ -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: diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index a9789d3..bd02088 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -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 diff --git a/src/fabledassistant/services/tasks.py b/src/fabledassistant/services/tasks.py index c7909f5..6e0df01 100644 --- a/src/fabledassistant/services/tasks.py +++ b/src/fabledassistant/services/tasks.py @@ -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) diff --git a/summary.md b/summary.md index d74e031..a79c3e7 100644 --- a/summary.md +++ b/summary.md @@ -3,8 +3,16 @@ > **Purpose:** This file is the canonical reference for re-initializing Claude Code > context on this project. **Update this file after every change session.** +## Session Checklist +> **IMPORTANT:** At the end of every change session, you MUST: +> 1. **Update this file** (`summary.md`) to reflect all architectural, data model, +> API, file structure, and roadmap changes made during the session. +> 2. **Commit all changes** with a descriptive commit message summarizing what was +> done (e.g., "Merge tasks into notes: single table with task attributes"). +> Include file-level details in the commit body when the change is non-trivial. + ## Last Updated -2026-02-09 — Phase 3.5 complete (Task-Note companions, wikilink auto-create, backlinks, bug fixes, Alembic migration infrastructure) +2026-02-10 — Phase 3.6 complete (Merged tasks into notes — a task is just a note with task attributes) ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with @@ -41,14 +49,16 @@ for AI-assisted features. `LIKE` prefix. - **Dark-first theming:** CSS custom properties on `:root` (light) and `[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark. -- **Task-Note companion link:** Every task automatically gets a companion note - (created in `create_task()`). Title changes sync bidirectionally. Deleting either - cascades to the other. `_skip_cascade` flag prevents infinite loops. +- **Unified note/task model:** A task is just a note with task attributes enabled. + A note has `status IS NOT NULL` ⟹ it's a task. "Convert to task" sets + `status='todo'`; "convert to note" clears `status`, `priority`, `due_date`. + No companion notes, no cascade deletes, no bidirectional sync needed. - **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create missing notes. -- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies and task - descriptions for `[[Title]]` patterns referencing the given note. +- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for + `[[Title]]` patterns referencing the given note. Results include `type: "note"` or + `type: "task"` based on whether the linking note has `status IS NOT NULL`. - **Idempotent raw SQL migrations:** All Alembic migrations use raw SQL with `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and @@ -82,24 +92,27 @@ for AI-assisted features. ## Data Model -### Notes (implemented) +### Notes (unified — includes tasks) - `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]), - `parent_id` (nullable FK to self), `created_at`, `updated_at` + `parent_id` (nullable FK to self), `status` (nullable str — `todo`/`in_progress`/`done`), + `priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date), + `created_at`, `updated_at` +- **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this - Tags are auto-extracted from body text on create/update via `#tag` regex - Supports hierarchical organization via `parent_id` - Lookup by exact title via `get_note_by_title()` for wikilink resolution - Auto-create via `get_or_create_note_by_title()` for wikilink clicks +- `to_dict()` returns: `id`, `title`, `body`, `tags`, `parent_id`, `status`, + `priority`, `due_date`, `is_task`, `created_at`, `updated_at` +- Indexes: GIN on tags, B-tree on status -### Tasks (implemented) -- `id` (int PK), `title` (str), `description` (markdown str), - `status` (enum: todo/in_progress/done), `priority` (enum: none/low/medium/high), - `due_date` (date, nullable), `note_id` (FK to notes, auto-created companion), - `tags` (ARRAY[str]), `created_at`, `updated_at` -- Tags auto-extracted from description on create/update -- Status enum with quick-toggle cycling: todo → in_progress → done → todo -- Companion note auto-created on task creation, title synced bidirectionally -- Deleting a task deletes its companion note (and vice versa) -- Indexes: GIN on tags, B-tree on note_id, B-tree on status +### Task ≡ Note with task attributes +- No separate tasks table. The `services/tasks.py` module is a thin wrapper + around `services/notes.py` that passes `is_task=True` for listing and + defaults `status='todo'`, `priority='none'` for creation. +- "Convert to task" = `update_note(id, status='todo', priority='none')` +- "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)` +- Task body field is `body` (not `description`) — standardized with notes ### LLM Interactions (Phase 4) - Summarize notes, generate task breakdowns, search/query across notes, @@ -118,24 +131,24 @@ fabledassistant/ │ └── versions/ │ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent) │ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent) -│ └── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks +│ ├── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks +│ └── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table ├── src/ │ └── fabledassistant/ │ ├── __init__.py │ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API │ ├── config.py # Config from env vars │ ├── models/ -│ │ ├── __init__.py # async_session factory, Base, imports Note + Task -│ │ ├── note.py # Note model (id, title, body, tags[], parent_id, timestamps) -│ │ └── task.py # Task model (id, title, description, status, priority, due_date, note_id, tags, timestamps) +│ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority +│ │ └── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property │ ├── routes/ │ │ ├── __init__.py │ │ ├── api.py # /api blueprint with /health endpoint -│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /backlinks -│ │ └── tasks.py # /api/tasks CRUD + PATCH status +│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks +│ │ └── tasks.py # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description) │ ├── services/ -│ │ ├── notes.py # CRUD, tag filter, get_or_create_by_title, convert_note_to_task, get_backlinks, cascade delete/sync -│ │ └── tasks.py # CRUD with auto companion note, cascade delete/sync, _skip_cascade flag +│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks +│ │ └── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.) │ ├── utils/ │ │ ├── __init__.py │ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences @@ -155,12 +168,12 @@ fabledassistant/ │ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme │ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search │ ├── stores/ - │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, fetchBacklinks, fetchAllTags - │ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus + │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags + │ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description) │ │ └── toast.ts # Toast notification state, 3s auto-dismiss │ ├── types/ - │ │ ├── note.ts # Note, NoteListResponse interfaces - │ │ └── task.ts # Task, TaskStatus, TaskPriority, TaskListResponse + │ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse + │ │ └── task.ts # Task = re-export of Note; TaskListResponse │ ├── utils/ │ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks() │ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards) @@ -168,14 +181,14 @@ fabledassistant/ │ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling) │ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination │ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete - │ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, companion task link, convert-to-task, backlinks + │ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task (only when !is_task), backlinks │ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle - │ │ ├── TaskEditorView.vue # Create/edit task: fields, companion note link, Ctrl+S, dirty guard, autocomplete - │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, companion note link, backlinks + │ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete + │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks │ ├── components/ │ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle │ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit - │ │ ├── TaskCard.vue # Card with rendered preview, StatusBadge (clickable), PriorityBadge, due date, tags + │ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags │ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling │ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none") │ │ ├── MarkdownToolbar.vue # Bold/italic/link/list/heading toolbar for editor @@ -193,22 +206,23 @@ fabledassistant/ | Method | Path | Description | |--------|------|-------------| | GET | `/api/health` | Health check | -| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`) | -| POST | `/api/notes` | Create note (body: `{title, body}` — tags auto-extracted) | -| GET | `/api/notes/tags` | List all tags from notes + tasks (param: `q` for filter) | +| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) | +| POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) | +| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) | | GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) | | POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) | -| GET | `/api/notes/:id` | Get single note | -| PUT | `/api/notes/:id` | Update note (body: `{title?, body?}` — tags re-extracted if body changes) | -| DELETE | `/api/notes/:id` | Delete note (cascades to companion task) | -| POST | `/api/notes/:id/convert-to-task` | Convert note into a task (deletes original note) | +| GET | `/api/notes/:id` | Get single note (response includes `is_task`, `status`, `priority`, `due_date`) | +| PUT | `/api/notes/:id` | Update note (body: `{title?, body?, status?, priority?, due_date?}` — tags re-extracted if body changes) | +| DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) | +| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` on note (returns 200) | +| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` from note (returns 200) | | GET | `/api/notes/:id/backlinks` | List notes/tasks that reference this note via wikilinks | -| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `note_id`, `sort`, `order`, `limit`, `offset`) | -| POST | `/api/tasks` | Create task (body: `{title, description, status?, priority?, due_date?}` — companion note auto-created) | +| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `sort`, `order`, `limit`, `offset`) — queries notes where `status IS NOT NULL` | +| POST | `/api/tasks` | Create task (body: `{title, body, status?, priority?, due_date?}` — accepts `description` as fallback for `body`) | | GET | `/api/tasks/:id` | Get single task | -| PUT | `/api/tasks/:id` | Update task (title syncs to companion note) | +| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) | | PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) | -| DELETE | `/api/tasks/:id` | Delete task (cascades to companion note) | +| DELETE | `/api/tasks/:id` | Delete task (simple delete) | ## Alembic Migrations @@ -219,7 +233,7 @@ container startup. ### Migration Chain ``` -0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py +0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py ``` ### How Migrations Run @@ -236,15 +250,15 @@ When adding a new migration, follow these conventions: 1. **Create the migration file:** ``` - alembic/versions/0004_description.py + alembic/versions/0005_description.py ``` 2. **Use raw SQL for idempotency:** ```python from alembic import op - revision = "0004" - down_revision = "0003" + revision = "0005" + down_revision = "0004" def upgrade() -> None: # For new enums: @@ -321,21 +335,14 @@ When adding a new migration, follow these conventions: ### Phase 3 — Tasks CRUD + Wikilinks ✓ - [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums - [x] Alembic migration for tasks table with PG enums and indexes -- [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/note_id/tags -- [x] Task-Note linking via optional `note_id` FK -- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (note-link autocomplete, Ctrl+S, dirty guard), viewer (rendered markdown) +- [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/tags +- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (Ctrl+S, dirty guard), viewer (rendered markdown) - [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components - [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown - [x] Wikilink click handling resolves notes by title via `/api/notes/by-title` -- [x] "Linked Tasks" section on NoteViewerView with inline status toggling -- [x] Theme variables for status/priority/wikilink/overdue colors (light + dark) ### Phase 3.5 — Note-Task Integration + Bug Fixes ✓ -- [x] **Task-Note companion link:** Every task auto-creates a companion note on creation -- [x] **Bidirectional title sync:** Renaming task syncs to companion note and vice versa -- [x] **Cascade delete:** Deleting a task deletes its companion note (and vice versa) - [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist -- [x] **Convert note to task:** Button on note viewer, creates task with companion note, deletes original - [x] **Backlinks system:** "What links here" section on note and task viewers - [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped) - [x] **Tag autocomplete Tab cycling:** Tab cycles through suggestions, single match accepts immediately @@ -344,7 +351,17 @@ When adding a new migration, follow these conventions: - [x] **500 error handler:** JSON error responses for API routes, traceback logging - [x] **Idempotent migrations:** All migrations rewritten to raw SQL with IF NOT EXISTS guards - [x] **Auto-migration on startup:** Dockerfile runs `alembic upgrade head` before starting app -- [x] **Data migration 0003:** Creates companion notes for pre-existing tasks + +### Phase 3.6 — Merge Tasks into Notes ✓ +- [x] **Unified note/task model:** Task is just a note with `status IS NOT NULL` +- [x] **Migration 0004:** Added `status`, `priority`, `due_date` columns to notes table, migrated task data from companion notes and orphan tasks, dropped `tasks` table +- [x] **Eliminated companion note system:** No more companion note creation, title sync, cascade deletes, or `_skip_cascade` flags +- [x] **Standardized on `body`:** Tasks use `body` everywhere (not `description`); API accepts `description` as fallback +- [x] **Convert to task:** Simple `update_note(id, status='todo', priority='none')` — same ID preserved +- [x] **Convert to note:** New `POST /api/notes/:id/convert-to-note` endpoint clears task attributes +- [x] **Tasks service as thin wrappers:** `services/tasks.py` delegates entirely to `services/notes.py` +- [x] **Frontend unified types:** `Task` is a re-export alias for `Note`; `note.ts` defines `TaskStatus`, `TaskPriority` +- [x] **Simplified views:** Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView ### Phase 4 — LLM Integration (next) - [ ] Ollama client in the backend (async HTTP via httpx/aiohttp) @@ -380,17 +397,16 @@ When adding a new migration, follow these conventions: - Should LLM streaming use WebSockets from Phase 4 or defer to Phase 5? ## Current Status -**Phase:** Phase 3.5 complete. Note-task companion system, wikilink auto-create, -backlinks, and bug fixes fully implemented. +**Phase:** Phase 3.6 complete. Tasks merged into notes — unified single-table model. +- Single `notes` table with optional `status`, `priority`, `due_date` columns +- A note **is a task** when `status IS NOT NULL` +- Convert between note ↔ task by setting/clearing task attributes (same ID preserved) +- No companion notes, no cascade deletes, no bidirectional sync +- Task body standardized as `body` (not `description`) +- `services/tasks.py` is a thin wrapper around `services/notes.py` +- Frontend `Task` type is an alias for `Note` - Full notes CRUD with markdown editing, rendering, and wikilinks -- Full tasks CRUD with status/priority enums, filters, companion note linking -- Automatic companion note creation for every task -- Bidirectional title sync and cascade delete between tasks and notes -- Convert note to task functionality -- Backlinks system showing "what links here" for notes and tasks -- Wikilink clicks auto-create missing notes -- Rendered markdown previews on list cards -- Tag autocomplete with Tab cycling -- Idempotent raw SQL migrations with auto-run on startup +- Full tasks CRUD with status/priority enums and filters +- Backlinks, wikilink auto-create, tag autocomplete all work across unified model - Dark/light theme with status/priority/wikilink color variables - Ready for Phase 4: LLM Integration