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
@@ -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")
+4 -4
View File
@@ -19,7 +19,7 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
};
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 {
<router-link :to="`/tasks/${task.id}`" class="task-card">
<div class="task-top">
<StatusBadge
:status="task.status"
:status="task.status!"
clickable
@click.prevent.stop="cycleStatus"
/>
<PriorityBadge :priority="task.priority" />
<PriorityBadge :priority="task.priority!" />
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
</div>
<div v-if="task.description" class="task-preview prose" v-html="renderPreview(task.description)"></div>
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
<div class="task-meta">
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
Due: {{ task.due_date }}
+17 -6
View File
@@ -142,17 +142,27 @@ export const useNotesStore = defineStore("notes", () => {
return await apiPost<Note>("/api/notes/resolve-title", { title });
}
async function convertToTask(noteId: number) {
const task = await apiPost<Record<string, unknown>>(
async function convertToTask(noteId: number): Promise<Note> {
const note = await apiPost<Note>(
`/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<Note> {
const note = await apiPost<Note>(
`/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,
};
+2 -2
View File
@@ -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<Task, "title" | "description" | "status" | "priority" | "due_date">
Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
>
): Promise<Task> {
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
+7
View File
@@ -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;
}
+2 -16
View File
@@ -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;
}
+8 -64
View File
@@ -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<HTMLElement | null>(null);
const linkedTasks = ref<Task[]>([]);
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<TaskListResponse>(`/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<TaskStatus, TaskStatus> = {
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<Task>(
`/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
</router-link>
<button
v-if="!hasCompanionTask"
v-if="!store.currentNote.is_task"
class="btn-convert"
@click="convertToTask"
:disabled="converting"
@@ -175,22 +141,6 @@ async function convertToTask() {
@click="onBodyClick"
></div>
<div v-if="linkedTasks.length" class="linked-tasks">
<h2>Companion Task</h2>
<ul class="linked-tasks-list">
<li v-for="task in linkedTasks" :key="task.id" class="linked-task-item">
<StatusBadge
:status="task.status"
clickable
@click="toggleTaskStatus(task)"
/>
<router-link :to="`/tasks/${task.id}`" class="linked-task-link">
{{ task.title || "Untitled" }}
</router-link>
</li>
</ul>
</div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
@@ -256,18 +206,15 @@ async function convertToTask() {
margin-bottom: 1rem;
flex-wrap: wrap;
}
.linked-tasks,
.backlinks {
margin-top: 2rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.linked-tasks h2,
.backlinks h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.linked-tasks-list,
.backlinks-list {
list-style: none;
margin: 0;
@@ -276,19 +223,16 @@ async function convertToTask() {
flex-direction: column;
gap: 0.5rem;
}
.linked-task-item,
.backlink-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.linked-task-link,
.backlink-link {
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
}
.linked-task-link:hover,
.backlink-link:hover {
color: var(--color-primary);
}
+17 -50
View File
@@ -6,9 +6,7 @@ import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAutocomplete } from "@/composables/useAutocomplete";
import { apiGet } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Note } from "@/types/note";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
const route = useRoute();
@@ -18,12 +16,10 @@ const notesStore = useNotesStore();
const toast = useToastStore();
const title = ref("");
const description = ref("");
const body = ref("");
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
const noteId = ref<number | null>(null);
const companionNote = ref<Note | null>(null);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -34,7 +30,7 @@ const taskId = computed(() =>
);
const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(description.value));
const renderedPreview = computed(() => renderMarkdown(body.value));
// Autocomplete
const {
@@ -47,10 +43,10 @@ const {
accept: acAccept,
dismiss: acDismiss,
onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, description, (q) => notesStore.fetchAllTags(q));
} = useAutocomplete(textareaRef, body, (q) => notesStore.fetchAllTags(q));
let savedTitle = "";
let savedDescription = "";
let savedBody = "";
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
@@ -58,7 +54,7 @@ let savedDueDate = "";
function markDirty() {
dirty.value =
title.value !== savedTitle ||
description.value !== savedDescription ||
body.value !== savedBody ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate;
@@ -71,7 +67,7 @@ function autoGrow() {
el.style.height = Math.max(el.scrollHeight, 200) + "px";
}
function onDescriptionInput() {
function onBodyInput() {
markDirty();
autoGrow();
detectTrigger();
@@ -93,10 +89,10 @@ function insertAtCursor(before: string, after: string, placeholder: string) {
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = description.value.slice(start, end);
const selected = body.value.slice(start, end);
const insert = selected || placeholder;
description.value =
description.value.slice(0, start) + before + insert + after + description.value.slice(end);
body.value =
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
markDirty();
nextTick(() => {
const newStart = start + before.length;
@@ -113,24 +109,15 @@ onMounted(async () => {
await store.fetchTask(taskId.value);
if (store.currentTask) {
title.value = store.currentTask.title;
description.value = store.currentTask.description;
status.value = store.currentTask.status;
priority.value = store.currentTask.priority;
body.value = store.currentTask.body;
status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || "";
noteId.value = store.currentTask.note_id;
savedTitle = title.value;
savedDescription = description.value;
savedBody = body.value;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
if (noteId.value) {
try {
companionNote.value = await apiGet<Note>(`/api/notes/${noteId.value}`);
} catch {
companionNote.value = null;
}
}
}
}
nextTick(autoGrow);
@@ -142,7 +129,7 @@ async function save() {
try {
const data = {
title: title.value,
description: description.value,
body: body.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
@@ -150,7 +137,7 @@ async function save() {
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
savedTitle = title.value;
savedDescription = description.value;
savedBody = body.value;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
@@ -254,10 +241,10 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<div class="textarea-wrapper" v-show="!showPreview">
<textarea
ref="textareaRef"
v-model="description"
v-model="body"
placeholder="Describe this task... Use #tags inline"
class="body-input"
@input="onDescriptionInput"
@input="onBodyInput"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
></textarea>
@@ -314,13 +301,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div>
</div>
<div v-if="companionNote" class="field companion-note">
<label>Companion Note</label>
<router-link :to="`/notes/${companionNote.id}`" class="note-link">
{{ companionNote.title || "Untitled" }}
</router-link>
</div>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
@@ -476,19 +456,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-text);
font-size: 0.9rem;
}
.companion-note {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.note-link {
color: var(--color-primary);
text-decoration: none;
font-size: 0.9rem;
}
.note-link:hover {
text-decoration: underline;
}
.modal-overlay {
position: fixed;
inset: 0;
+54 -42
View File
@@ -5,7 +5,7 @@ import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime";
import { apiGet, apiPost } from "@/api/client";
import { apiPost } from "@/api/client";
import type { Note } from "@/types/note";
import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
@@ -16,28 +16,18 @@ const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const linkedNote = ref<Note | null>(null);
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false);
const taskId = computed(() => Number(route.params.id));
async function loadTask(id: number) {
linkedNote.value = null;
backlinks.value = [];
await store.fetchTask(id);
if (store.currentTask?.note_id) {
try {
linkedNote.value = await apiGet<Note>(
`/api/notes/${store.currentTask.note_id}`
);
} catch {
linkedNote.value = null;
}
try {
backlinks.value = await notesStore.fetchBacklinks(store.currentTask.note_id);
} catch {
backlinks.value = [];
}
try {
backlinks.value = await notesStore.fetchBacklinks(id);
} catch {
backlinks.value = [];
}
}
@@ -47,9 +37,9 @@ watch(() => route.params.id, (newId) => {
if (newId) loadTask(Number(newId));
});
const renderedDescription = computed(() => {
const renderedBody = computed(() => {
if (!store.currentTask) return "";
return renderMarkdown(store.currentTask.description);
return renderMarkdown(store.currentTask.body);
});
const statusCycle: Record<TaskStatus, TaskStatus> = {
@@ -62,7 +52,7 @@ function cycleStatus() {
if (!store.currentTask) return;
store.patchStatus(
store.currentTask.id,
statusCycle[store.currentTask.status]
statusCycle[store.currentTask.status as TaskStatus]
);
}
@@ -74,6 +64,22 @@ function isOverdue(): boolean {
);
}
async function convertToNote() {
if (converting.value) return;
converting.value = true;
try {
await notesStore.convertToNote(taskId.value);
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Converted to note");
router.push(`/notes/${taskId.value}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Failed to convert task", "error");
} finally {
converting.value = false;
}
}
async function onBodyClick(e: MouseEvent) {
const target = e.target as HTMLElement;
@@ -123,6 +129,13 @@ function onTagClick(tag: string) {
>
Edit
</router-link>
<button
class="btn-convert"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
</div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
@@ -132,11 +145,11 @@ function onTagClick(tag: string) {
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status"
:status="store.currentTask.status!"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority" />
<PriorityBadge :priority="store.currentTask.priority!" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
@@ -144,14 +157,6 @@ function onTagClick(tag: string) {
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div
v-if="linkedNote"
class="linked-note"
>
<router-link :to="`/notes/${linkedNote.id}`" class="note-link">
View Note
</router-link>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
@@ -162,7 +167,7 @@ function onTagClick(tag: string) {
</div>
<div
class="body prose"
v-html="renderedDescription"
v-html="renderedBody"
@click="onBodyClick"
></div>
@@ -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;
+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)
+86 -70
View File
@@ -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