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() { 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 { function isOverdue(): boolean {
@@ -32,14 +32,14 @@ function isOverdue(): boolean {
<router-link :to="`/tasks/${task.id}`" class="task-card"> <router-link :to="`/tasks/${task.id}`" class="task-card">
<div class="task-top"> <div class="task-top">
<StatusBadge <StatusBadge
:status="task.status" :status="task.status!"
clickable clickable
@click.prevent.stop="cycleStatus" @click.prevent.stop="cycleStatus"
/> />
<PriorityBadge :priority="task.priority" /> <PriorityBadge :priority="task.priority!" />
<h3 class="task-title">{{ task.title || "Untitled" }}</h3> <h3 class="task-title">{{ task.title || "Untitled" }}</h3>
</div> </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"> <div class="task-meta">
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]"> <span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
Due: {{ task.due_date }} 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 }); return await apiPost<Note>("/api/notes/resolve-title", { title });
} }
async function convertToTask(noteId: number) { async function convertToTask(noteId: number): Promise<Note> {
const task = await apiPost<Record<string, unknown>>( const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-task`, `/api/notes/${noteId}/convert-to-task`,
{} {}
); );
// Remove the note from local state since it was converted // Update local state — the note is now a task
notes.value = notes.value.filter((n) => n.id !== noteId);
if (currentNote.value?.id === noteId) { 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( async function fetchBacklinks(
@@ -196,6 +206,7 @@ export const useNotesStore = defineStore("notes", () => {
refresh, refresh,
resolveTitle, resolveTitle,
convertToTask, convertToTask,
convertToNote,
fetchBacklinks, fetchBacklinks,
fetchAllTags, fetchAllTags,
}; };
+2 -2
View File
@@ -57,7 +57,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function createTask(data: { async function createTask(data: {
title: string; title: string;
description: string; body: string;
status?: TaskStatus; status?: TaskStatus;
priority?: TaskPriority; priority?: TaskPriority;
due_date?: string | null; due_date?: string | null;
@@ -68,7 +68,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask( async function updateTask(
id: number, id: number,
data: Partial< data: Partial<
Pick<Task, "title" | "description" | "status" | "priority" | "due_date"> Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
> >
): Promise<Task> { ): Promise<Task> {
const task = await apiPut<Task>(`/api/tasks/${id}`, data); 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 { export interface Note {
id: number; id: number;
title: string; title: string;
body: string; body: string;
tags: string[]; tags: string[];
parent_id: number | null; parent_id: number | null;
status: TaskStatus | null;
priority: TaskPriority | null;
due_date: string | null;
is_task: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
+2 -16
View File
@@ -1,20 +1,6 @@
export type TaskStatus = "todo" | "in_progress" | "done"; export type { TaskStatus, TaskPriority, Note as Task } from "./note";
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 interface TaskListResponse { export interface TaskListResponse {
tasks: Task[]; tasks: import("./note").Note[];
total: number; total: number;
} }
+8 -64
View File
@@ -4,25 +4,20 @@ import { useRoute, useRouter } from "vue-router";
import { useNotesStore } from "@/stores/notes"; import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime"; import { relativeTime } from "@/composables/useRelativeTime";
import { apiGet, apiPost } from "@/api/client"; import { apiPost } from "@/api/client";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { Note } from "@/types/note"; import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue"; import TagPill from "@/components/TagPill.vue";
import StatusBadge from "@/components/StatusBadge.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const store = useNotesStore(); const store = useNotesStore();
const bodyEl = ref<HTMLElement | null>(null); const bodyEl = ref<HTMLElement | null>(null);
const linkedTasks = ref<Task[]>([]);
const backlinks = ref<{ type: string; id: number; title: string }[]>([]); const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false); const converting = ref(false);
const noteId = computed(() => Number(route.params.id)); const noteId = computed(() => Number(route.params.id));
const hasCompanionTask = computed(() => linkedTasks.value.length > 0);
async function loadNote(id: number) { async function loadNote(id: number) {
linkedTasks.value = [];
backlinks.value = []; backlinks.value = [];
await store.fetchNote(id); await store.fetchNote(id);
@@ -32,16 +27,10 @@ async function loadNote(id: number) {
return; return;
} }
// Fetch tasks linked to this note & backlinks in parallel try {
const [taskData, backlinkData] = await Promise.allSettled([ backlinks.value = await store.fetchBacklinks(id);
apiGet<TaskListResponse>(`/api/tasks?note_id=${id}`), } catch {
store.fetchBacklinks(id), backlinks.value = [];
]);
if (taskData.status === "fulfilled") {
linkedTasks.value = taskData.value.tasks;
}
if (backlinkData.status === "fulfilled") {
backlinks.value = backlinkData.value;
} }
} }
@@ -93,37 +82,14 @@ function onTagClick(tag: string) {
router.push({ path: "/notes", query: { tag } }); 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() { async function convertToTask() {
if (converting.value) return; if (converting.value) return;
converting.value = true; converting.value = true;
try { try {
const task = await store.convertToTask(noteId.value); await store.convertToTask(noteId.value);
const { useToastStore } = await import("@/stores/toast"); const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Converted to task"); useToastStore().show("Converted to task");
router.push(`/tasks/${task.id}`); router.push(`/tasks/${noteId.value}`);
} catch { } catch {
const { useToastStore } = await import("@/stores/toast"); const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Failed to convert note", "error"); useToastStore().show("Failed to convert note", "error");
@@ -146,7 +112,7 @@ async function convertToTask() {
Edit Edit
</router-link> </router-link>
<button <button
v-if="!hasCompanionTask" v-if="!store.currentNote.is_task"
class="btn-convert" class="btn-convert"
@click="convertToTask" @click="convertToTask"
:disabled="converting" :disabled="converting"
@@ -175,22 +141,6 @@ async function convertToTask() {
@click="onBodyClick" @click="onBodyClick"
></div> ></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"> <div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2> <h2>Backlinks</h2>
<ul class="backlinks-list"> <ul class="backlinks-list">
@@ -256,18 +206,15 @@ async function convertToTask() {
margin-bottom: 1rem; margin-bottom: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.linked-tasks,
.backlinks { .backlinks {
margin-top: 2rem; margin-top: 2rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-top: 1rem; padding-top: 1rem;
} }
.linked-tasks h2,
.backlinks h2 { .backlinks h2 {
font-size: 1.1rem; font-size: 1.1rem;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
.linked-tasks-list,
.backlinks-list { .backlinks-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -276,19 +223,16 @@ async function convertToTask() {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.linked-task-item,
.backlink-item { .backlink-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.linked-task-link,
.backlink-link { .backlink-link {
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
font-size: 0.95rem; font-size: 0.95rem;
} }
.linked-task-link:hover,
.backlink-link:hover { .backlink-link:hover {
color: var(--color-primary); color: var(--color-primary);
} }
+17 -50
View File
@@ -6,9 +6,7 @@ import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { useAutocomplete } from "@/composables/useAutocomplete"; import { useAutocomplete } from "@/composables/useAutocomplete";
import { apiGet } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task"; import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Note } from "@/types/note";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
const route = useRoute(); const route = useRoute();
@@ -18,12 +16,10 @@ const notesStore = useNotesStore();
const toast = useToastStore(); const toast = useToastStore();
const title = ref(""); const title = ref("");
const description = ref(""); const body = ref("");
const status = ref<TaskStatus>("todo"); const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none"); const priority = ref<TaskPriority>("none");
const dueDate = ref(""); const dueDate = ref("");
const noteId = ref<number | null>(null);
const companionNote = ref<Note | null>(null);
const dirty = ref(false); const dirty = ref(false);
const saving = ref(false); const saving = ref(false);
const showPreview = ref(false); const showPreview = ref(false);
@@ -34,7 +30,7 @@ const taskId = computed(() =>
); );
const isEditing = computed(() => taskId.value !== null); const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(description.value)); const renderedPreview = computed(() => renderMarkdown(body.value));
// Autocomplete // Autocomplete
const { const {
@@ -47,10 +43,10 @@ const {
accept: acAccept, accept: acAccept,
dismiss: acDismiss, dismiss: acDismiss,
onKeydown: acOnKeydown, onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, description, (q) => notesStore.fetchAllTags(q)); } = useAutocomplete(textareaRef, body, (q) => notesStore.fetchAllTags(q));
let savedTitle = ""; let savedTitle = "";
let savedDescription = ""; let savedBody = "";
let savedStatus: TaskStatus = "todo"; let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none"; let savedPriority: TaskPriority = "none";
let savedDueDate = ""; let savedDueDate = "";
@@ -58,7 +54,7 @@ let savedDueDate = "";
function markDirty() { function markDirty() {
dirty.value = dirty.value =
title.value !== savedTitle || title.value !== savedTitle ||
description.value !== savedDescription || body.value !== savedBody ||
status.value !== savedStatus || status.value !== savedStatus ||
priority.value !== savedPriority || priority.value !== savedPriority ||
dueDate.value !== savedDueDate; dueDate.value !== savedDueDate;
@@ -71,7 +67,7 @@ function autoGrow() {
el.style.height = Math.max(el.scrollHeight, 200) + "px"; el.style.height = Math.max(el.scrollHeight, 200) + "px";
} }
function onDescriptionInput() { function onBodyInput() {
markDirty(); markDirty();
autoGrow(); autoGrow();
detectTrigger(); detectTrigger();
@@ -93,10 +89,10 @@ function insertAtCursor(before: string, after: string, placeholder: string) {
el.focus(); el.focus();
const start = el.selectionStart; const start = el.selectionStart;
const end = el.selectionEnd; const end = el.selectionEnd;
const selected = description.value.slice(start, end); const selected = body.value.slice(start, end);
const insert = selected || placeholder; const insert = selected || placeholder;
description.value = body.value =
description.value.slice(0, start) + before + insert + after + description.value.slice(end); body.value.slice(0, start) + before + insert + after + body.value.slice(end);
markDirty(); markDirty();
nextTick(() => { nextTick(() => {
const newStart = start + before.length; const newStart = start + before.length;
@@ -113,24 +109,15 @@ onMounted(async () => {
await store.fetchTask(taskId.value); await store.fetchTask(taskId.value);
if (store.currentTask) { if (store.currentTask) {
title.value = store.currentTask.title; title.value = store.currentTask.title;
description.value = store.currentTask.description; body.value = store.currentTask.body;
status.value = store.currentTask.status; status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority; priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || ""; dueDate.value = store.currentTask.due_date || "";
noteId.value = store.currentTask.note_id;
savedTitle = title.value; savedTitle = title.value;
savedDescription = description.value; savedBody = body.value;
savedStatus = status.value; savedStatus = status.value;
savedPriority = priority.value; savedPriority = priority.value;
savedDueDate = dueDate.value; savedDueDate = dueDate.value;
if (noteId.value) {
try {
companionNote.value = await apiGet<Note>(`/api/notes/${noteId.value}`);
} catch {
companionNote.value = null;
}
}
} }
} }
nextTick(autoGrow); nextTick(autoGrow);
@@ -142,7 +129,7 @@ async function save() {
try { try {
const data = { const data = {
title: title.value, title: title.value,
description: description.value, body: body.value,
status: status.value, status: status.value,
priority: priority.value, priority: priority.value,
due_date: dueDate.value || null, due_date: dueDate.value || null,
@@ -150,7 +137,7 @@ async function save() {
if (isEditing.value) { if (isEditing.value) {
await store.updateTask(taskId.value!, data); await store.updateTask(taskId.value!, data);
savedTitle = title.value; savedTitle = title.value;
savedDescription = description.value; savedBody = body.value;
savedStatus = status.value; savedStatus = status.value;
savedPriority = priority.value; savedPriority = priority.value;
savedDueDate = dueDate.value; savedDueDate = dueDate.value;
@@ -254,10 +241,10 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<div class="textarea-wrapper" v-show="!showPreview"> <div class="textarea-wrapper" v-show="!showPreview">
<textarea <textarea
ref="textareaRef" ref="textareaRef"
v-model="description" v-model="body"
placeholder="Describe this task... Use #tags inline" placeholder="Describe this task... Use #tags inline"
class="body-input" class="body-input"
@input="onDescriptionInput" @input="onBodyInput"
@keydown="onTextareaKeydown" @keydown="onTextareaKeydown"
@blur="onTextareaBlur" @blur="onTextareaBlur"
></textarea> ></textarea>
@@ -314,13 +301,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div> </div>
</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 --> <!-- Delete confirmation -->
<teleport to="body"> <teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false"> <div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
@@ -476,19 +456,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-text); color: var(--color-text);
font-size: 0.9rem; 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 { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
+54 -42
View File
@@ -5,7 +5,7 @@ import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes"; import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime"; import { relativeTime } from "@/composables/useRelativeTime";
import { apiGet, apiPost } from "@/api/client"; import { apiPost } from "@/api/client";
import type { Note } from "@/types/note"; import type { Note } from "@/types/note";
import type { TaskStatus } from "@/types/task"; import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue"; import StatusBadge from "@/components/StatusBadge.vue";
@@ -16,28 +16,18 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const store = useTasksStore(); const store = useTasksStore();
const notesStore = useNotesStore(); const notesStore = useNotesStore();
const linkedNote = ref<Note | null>(null);
const backlinks = ref<{ type: string; id: number; title: string }[]>([]); const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false);
const taskId = computed(() => Number(route.params.id)); const taskId = computed(() => Number(route.params.id));
async function loadTask(id: number) { async function loadTask(id: number) {
linkedNote.value = null;
backlinks.value = []; backlinks.value = [];
await store.fetchTask(id); await store.fetchTask(id);
if (store.currentTask?.note_id) { try {
try { backlinks.value = await notesStore.fetchBacklinks(id);
linkedNote.value = await apiGet<Note>( } catch {
`/api/notes/${store.currentTask.note_id}` backlinks.value = [];
);
} catch {
linkedNote.value = null;
}
try {
backlinks.value = await notesStore.fetchBacklinks(store.currentTask.note_id);
} catch {
backlinks.value = [];
}
} }
} }
@@ -47,9 +37,9 @@ watch(() => route.params.id, (newId) => {
if (newId) loadTask(Number(newId)); if (newId) loadTask(Number(newId));
}); });
const renderedDescription = computed(() => { const renderedBody = computed(() => {
if (!store.currentTask) return ""; if (!store.currentTask) return "";
return renderMarkdown(store.currentTask.description); return renderMarkdown(store.currentTask.body);
}); });
const statusCycle: Record<TaskStatus, TaskStatus> = { const statusCycle: Record<TaskStatus, TaskStatus> = {
@@ -62,7 +52,7 @@ function cycleStatus() {
if (!store.currentTask) return; if (!store.currentTask) return;
store.patchStatus( store.patchStatus(
store.currentTask.id, 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) { async function onBodyClick(e: MouseEvent) {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -123,6 +129,13 @@ function onTagClick(tag: string) {
> >
Edit Edit
</router-link> </router-link>
<button
class="btn-convert"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
</div> </div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1> <h1>{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta"> <p class="meta">
@@ -132,11 +145,11 @@ function onTagClick(tag: string) {
</p> </p>
<div class="badges"> <div class="badges">
<StatusBadge <StatusBadge
:status="store.currentTask.status" :status="store.currentTask.status!"
clickable clickable
@click="cycleStatus" @click="cycleStatus"
/> />
<PriorityBadge :priority="store.currentTask.priority" /> <PriorityBadge :priority="store.currentTask.priority!" />
<span <span
v-if="store.currentTask.due_date" v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]" :class="['due-date', { overdue: isOverdue() }]"
@@ -144,14 +157,6 @@ function onTagClick(tag: string) {
Due: {{ store.currentTask.due_date }} Due: {{ store.currentTask.due_date }}
</span> </span>
</div> </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"> <div class="tags" v-if="store.currentTask.tags.length">
<TagPill <TagPill
v-for="tag in store.currentTask.tags" v-for="tag in store.currentTask.tags"
@@ -162,7 +167,7 @@ function onTagClick(tag: string) {
</div> </div>
<div <div
class="body prose" class="body prose"
v-html="renderedDescription" v-html="renderedBody"
@click="onBodyClick" @click="onBodyClick"
></div> ></div>
@@ -201,6 +206,25 @@ function onTagClick(tag: string) {
text-decoration: none; text-decoration: none;
color: var(--color-primary); 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 { .meta {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -220,18 +244,6 @@ function onTagClick(tag: string) {
color: var(--color-overdue); color: var(--color-overdue);
font-weight: 600; 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 { .tags {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
+1 -2
View File
@@ -11,5 +11,4 @@ class Base(DeclarativeBase):
pass pass
from fabledassistant.models.note import Note # noqa: E402, F401 from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from fabledassistant.models.task import Task # 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.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base 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): class Note(Base):
__tablename__ = "notes" __tablename__ = "notes"
@@ -15,8 +29,11 @@ class Note(Base):
body: Mapped[str] = mapped_column(Text, default="") body: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
parent_id: Mapped[int | None] = mapped_column( 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -26,7 +43,14 @@ class Note(Base):
onupdate=lambda: datetime.now(timezone.utc), 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: def to_dict(self) -> dict:
return { return {
@@ -35,6 +59,10 @@ class Note(Base):
"body": self.body, "body": self.body,
"tags": self.tags or [], "tags": self.tags or [],
"parent_id": self.parent_id, "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(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_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 quart import Blueprint, jsonify, request
from fabledassistant.services.notes import ( from fabledassistant.services.notes import (
convert_note_to_task, convert_note_to_task,
convert_task_to_note,
create_note, create_note,
delete_note, delete_note,
get_all_tags, get_all_tags,
@@ -26,8 +29,15 @@ async def list_notes_route():
limit = request.args.get("limit", 50, type=int) limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, 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( 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}) 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() data = await request.get_json()
body = data.get("body", "") body = data.get("body", "")
tags = extract_tags(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( note = await create_note(
title=data.get("title", ""), title=data.get("title", ""),
body=body, body=body,
tags=tags, tags=tags,
parent_id=data.get("parent_id"), parent_id=data.get("parent_id"),
status=status,
priority=priority,
due_date=due_date,
) )
return jsonify(note.to_dict()), 201 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): async def update_note_route(note_id: int):
data = await request.get_json() data = await request.get_json()
fields = {} fields = {}
for key in ("title", "body", "parent_id"): for key in ("title", "body", "parent_id", "status", "priority"):
if key in data: if key in data:
fields[key] = data[key] 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: if "body" in fields:
fields["tags"] = extract_tags(fields["body"]) fields["tags"] = extract_tags(fields["body"])
note = await update_note(note_id, **fields) 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"]) @notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
async def convert_note_to_task_route(note_id: int): async def convert_note_to_task_route(note_id: int):
try: try:
task = await convert_note_to_task(note_id) note = await convert_note_to_task(note_id)
return jsonify(task.to_dict()), 201 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: except ValueError as e:
return jsonify({"error": str(e)}), 404 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 quart import Blueprint, jsonify, request
from fabledassistant.models.task import TaskPriority, TaskStatus from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.services.tasks import ( from fabledassistant.services.tasks import (
create_task, create_task,
delete_task, delete_task,
@@ -21,7 +21,6 @@ async def list_tasks_route():
tag = request.args.getlist("tag") tag = request.args.getlist("tag")
status = request.args.get("status") status = request.args.get("status")
priority = request.args.get("priority") priority = request.args.get("priority")
note_id = request.args.get("note_id", type=int)
sort = request.args.get("sort", "updated_at") sort = request.args.get("sort", "updated_at")
order = request.args.get("order", "desc") order = request.args.get("order", "desc")
limit = request.args.get("limit", 50, type=int) limit = request.args.get("limit", 50, type=int)
@@ -32,7 +31,6 @@ async def list_tasks_route():
tags=tag or None, tags=tag or None,
status=status, status=status,
priority=priority, priority=priority,
note_id=note_id,
sort=sort, sort=sort,
order=order, order=order,
limit=limit, limit=limit,
@@ -44,21 +42,21 @@ async def list_tasks_route():
@tasks_bp.route("", methods=["POST"]) @tasks_bp.route("", methods=["POST"])
async def create_task_route(): async def create_task_route():
data = await request.get_json() data = await request.get_json()
description = data.get("description", "") body = data.get("body", "") or data.get("description", "")
tags = extract_tags(description) tags = extract_tags(body)
due_date = None due_date = None
if data.get("due_date"): if data.get("due_date"):
due_date = date.fromisoformat(data["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 = ( 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( task = await create_task(
title=data.get("title", ""), title=data.get("title", ""),
description=description, body=body,
status=status, status=status,
priority=priority, priority=priority,
due_date=due_date, due_date=due_date,
@@ -79,17 +77,23 @@ async def get_task_route(task_id: int):
async def update_task_route(task_id: int): async def update_task_route(task_id: int):
data = await request.get_json() data = await request.get_json()
fields = {} fields = {}
for key in ("title", "description", "status", "priority"): for key in ("title", "status", "priority"):
if key in data: if key in data:
fields[key] = data[key] 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: if "due_date" in data:
fields["due_date"] = ( fields["due_date"] = (
date.fromisoformat(data["due_date"]) if data["due_date"] else None date.fromisoformat(data["due_date"]) if data["due_date"] else None
) )
if "description" in fields: if "body" in fields:
fields["tags"] = extract_tags(fields["description"]) fields["tags"] = extract_tags(fields["body"])
task = await update_task(task_id, **fields) task = await update_task(task_id, **fields)
if task is None: 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 sqlalchemy import func, or_, select, text
from fabledassistant.models import async_session from fabledassistant.models import async_session
from fabledassistant.models.note import Note from fabledassistant.models.note import Note, TaskPriority, TaskStatus
from fabledassistant.models.task import Task
async def create_note( async def create_note(
@@ -12,6 +11,9 @@ async def create_note(
body: str = "", body: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
parent_id: int | None = None, parent_id: int | None = None,
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
) -> Note: ) -> Note:
async with async_session() as session: async with async_session() as session:
note = Note( note = Note(
@@ -19,6 +21,9 @@ async def create_note(
body=body, body=body,
tags=tags or [], tags=tags or [],
parent_id=parent_id, parent_id=parent_id,
status=status,
priority=priority,
due_date=due_date,
) )
session.add(note) session.add(note)
await session.commit() await session.commit()
@@ -34,6 +39,9 @@ async def get_note(note_id: int) -> Note | None:
async def list_notes( async def list_notes(
q: str | None = None, q: str | None = None,
tags: list[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", sort: str = "updated_at",
order: str = "desc", order: str = "desc",
limit: int = 50, limit: int = 50,
@@ -43,6 +51,14 @@ async def list_notes(
query = select(Note) query = select(Note)
count_query = select(func.count(Note.id)) 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: if q:
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped_q}%" pattern = f"%{escaped_q}%"
@@ -61,6 +77,14 @@ async def list_notes(
query = query.where(tag_filter) query = query.where(tag_filter)
count_query = count_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) sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc": if order == "asc":
query = query.order_by(sort_col.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) return await create_note(title=title)
async def update_note( async def update_note(note_id: int, **fields: object) -> Note | None:
note_id: int, _skip_cascade: bool = False, **fields: object
) -> Note | None:
async with async_session() as session: async with async_session() as session:
note = await session.get(Note, note_id) note = await session.get(Note, note_id)
if note is None: if note is None:
return None return None
for key, value in fields.items(): for key, value in fields.items():
if hasattr(note, key): if not hasattr(note, key):
setattr(note, key, value) 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) 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.commit()
await session.refresh(note) await session.refresh(note)
return 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: async with async_session() as session:
note = await session.get(Note, note_id) note = await session.get(Note, note_id)
if note is None: if note is None:
return False 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.delete(note)
await session.commit() await session.commit()
return True return True
@@ -140,11 +148,10 @@ async def get_all_tags(q: str | None = None) -> list[str]:
async with async_session() as session: async with async_session() as session:
all_tags: set[str] = set() all_tags: set[str] = set()
for Model in (Note, Task): result = await session.execute(select(Note))
result = await session.execute(select(Model)) for obj in result.scalars():
for obj in result.scalars(): if obj.tags:
if obj.tags: all_tags.update(obj.tags)
all_tags.update(obj.tags)
if q: if q:
q_lower = q.lower() q_lower = q.lower()
@@ -153,24 +160,20 @@ async def get_all_tags(q: str | None = None) -> list[str]:
return sorted(all_tags) return sorted(all_tags)
async def convert_note_to_task(note_id: int) -> Task: async def convert_note_to_task(note_id: int) -> Note:
from fabledassistant.services.tasks import create_task
note = await get_note(note_id) note = await get_note(note_id)
if note is None: if note is None:
raise ValueError("Note not found") 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 async def convert_task_to_note(note_id: int) -> Note:
if task.note_id: note = await get_note(note_id)
await update_note(task.note_id, _skip_cascade=True, body=note.body, tags=list(note.tags)) if note is None:
raise ValueError("Note not found")
# Delete the original note (skip cascade since we're managing this) updated = await update_note(note_id, status=None, priority=None, due_date=None)
await delete_note(note_id, _skip_cascade=True) return updated
return task
async def get_backlinks(note_id: int) -> list[dict]: async def get_backlinks(note_id: int) -> list[dict]:
@@ -183,33 +186,19 @@ async def get_backlinks(note_id: int) -> list[dict]:
return [] return []
async with async_session() as session: async with async_session() as session:
# Escape SQL LIKE wildcards in title
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
# Search for [[Title]] or [[Title|...]] in note bodies
pattern = f"%[[{escaped}]]%" pattern = f"%[[{escaped}]]%"
pattern_alias = f"%[[{escaped}|%" pattern_alias = f"%[[{escaped}|%"
# Find notes with backlinks (exclude this note itself) results = await session.execute(
note_results = await session.execute( select(Note.id, Note.title, Note.status).where(
select(Note.id, Note.title).where(
Note.id != note_id, Note.id != note_id,
or_(Note.body.like(pattern), Note.body.like(pattern_alias)), or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
) )
) )
backlinks: list[dict] = [] backlinks: list[dict] = []
for row in note_results.fetchall(): for row in results.fetchall():
backlinks.append({"type": "note", "id": row[0], "title": row[1]}) link_type = "task" if row[2] is not None else "note"
backlinks.append({"type": link_type, "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]})
return backlinks 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.note import Note, TaskPriority, TaskStatus
from fabledassistant.services.notes import (
from fabledassistant.models import async_session create_note,
from fabledassistant.models.note import Note delete_note,
from fabledassistant.models.task import Task, TaskPriority, TaskStatus get_note,
list_notes,
update_note,
)
async def create_task( async def create_task(
title: str = "", title: str = "",
description: str = "", body: str = "",
status: TaskStatus = TaskStatus.todo, status: str = TaskStatus.todo.value,
priority: TaskPriority = TaskPriority.none, priority: str = TaskPriority.none.value,
due_date=None, due_date: date | None = None,
tags: list[str] | None = None, tags: list[str] | None = None,
) -> Task: ) -> Note:
async with async_session() as session: return await create_note(
# Auto-create companion note title=title,
companion = Note(title=title, body="", tags=tags or []) body=body,
session.add(companion) tags=tags,
await session.flush() status=status,
priority=priority,
task = Task( due_date=due_date,
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
async def get_task(task_id: int) -> Task | None: async def get_task(task_id: int) -> Note | None:
async with async_session() as session: return await get_note(task_id)
return await session.get(Task, task_id)
async def list_tasks( async def list_tasks(
@@ -46,100 +37,27 @@ async def list_tasks(
tags: list[str] | None = None, tags: list[str] | None = None,
status: str | None = None, status: str | None = None,
priority: str | None = None, priority: str | None = None,
note_id: int | None = None,
sort: str = "updated_at", sort: str = "updated_at",
order: str = "desc", order: str = "desc",
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
) -> tuple[list[Task], int]: ) -> tuple[list[Note], int]:
async with async_session() as session: return await list_notes(
query = select(Task) q=q,
count_query = select(func.count(Task.id)) tags=tags,
is_task=True,
if q: status=status,
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") priority=priority,
pattern = f"%{escaped_q}%" sort=sort,
filter_ = or_(Task.title.ilike(pattern), Task.description.ilike(pattern)) order=order,
query = query.where(filter_) limit=limit,
count_query = count_query.where(filter_) offset=offset,
)
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
async def update_task( async def update_task(task_id: int, **fields: object) -> Note | None:
task_id: int, _skip_cascade: bool = False, **fields: object return await update_note(task_id, **fields)
) -> 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 delete_task(task_id: int, _skip_cascade: bool = False) -> bool: async def delete_task(task_id: int) -> bool:
async with async_session() as session: return await delete_note(task_id)
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
+86 -70
View File
@@ -3,8 +3,16 @@
> **Purpose:** This file is the canonical reference for re-initializing Claude Code > **Purpose:** This file is the canonical reference for re-initializing Claude Code
> context on this project. **Update this file after every change session.** > 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 ## 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 ## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -41,14 +49,16 @@ for AI-assisted features.
`LIKE` prefix. `LIKE` prefix.
- **Dark-first theming:** CSS custom properties on `:root` (light) and - **Dark-first theming:** CSS custom properties on `:root` (light) and
`[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark. `[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark.
- **Task-Note companion link:** Every task automatically gets a companion note - **Unified note/task model:** A task is just a note with task attributes enabled.
(created in `create_task()`). Title changes sync bidirectionally. Deleting either A note has `status IS NOT NULL` ⟹ it's a task. "Convert to task" sets
cascades to the other. `_skip_cascade` flag prevents infinite loops. `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 - **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
missing notes. missing notes.
- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies and task - **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for
descriptions for `[[Title]]` patterns referencing the given note. `[[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 - **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 TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN
CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and
@@ -82,24 +92,27 @@ for AI-assisted features.
## Data Model ## Data Model
### Notes (implemented) ### Notes (unified — includes tasks)
- `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]), - `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 - Tags are auto-extracted from body text on create/update via `#tag` regex
- Supports hierarchical organization via `parent_id` - Supports hierarchical organization via `parent_id`
- Lookup by exact title via `get_note_by_title()` for wikilink resolution - Lookup by exact title via `get_note_by_title()` for wikilink resolution
- Auto-create via `get_or_create_note_by_title()` for wikilink clicks - 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) ### Task ≡ Note with task attributes
- `id` (int PK), `title` (str), `description` (markdown str), - No separate tasks table. The `services/tasks.py` module is a thin wrapper
`status` (enum: todo/in_progress/done), `priority` (enum: none/low/medium/high), around `services/notes.py` that passes `is_task=True` for listing and
`due_date` (date, nullable), `note_id` (FK to notes, auto-created companion), defaults `status='todo'`, `priority='none'` for creation.
`tags` (ARRAY[str]), `created_at`, `updated_at` - "Convert to task" = `update_note(id, status='todo', priority='none')`
- Tags auto-extracted from description on create/update - "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)`
- Status enum with quick-toggle cycling: todo → in_progress → done → todo - Task body field is `body` (not `description`) — standardized with notes
- 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
### LLM Interactions (Phase 4) ### LLM Interactions (Phase 4)
- Summarize notes, generate task breakdowns, search/query across notes, - Summarize notes, generate task breakdowns, search/query across notes,
@@ -118,24 +131,24 @@ fabledassistant/
│ └── versions/ │ └── versions/
│ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent) │ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent)
│ ├── 0002_create_tasks_table.py # Tasks table + enums (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/ ├── src/
│ └── fabledassistant/ │ └── fabledassistant/
│ ├── __init__.py │ ├── __init__.py
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API │ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API
│ ├── config.py # Config from env vars │ ├── config.py # Config from env vars
│ ├── models/ │ ├── models/
│ │ ├── __init__.py # async_session factory, Base, imports Note + Task │ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority
│ │ ── note.py # Note model (id, title, body, tags[], parent_id, timestamps) │ │ ── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
│ │ └── task.py # Task model (id, title, description, status, priority, due_date, note_id, tags, timestamps)
│ ├── routes/ │ ├── routes/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint │ │ ├── api.py # /api blueprint with /health endpoint
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /backlinks │ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
│ │ └── tasks.py # /api/tasks CRUD + PATCH status │ │ └── tasks.py # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description)
│ ├── services/ │ ├── services/
│ │ ├── notes.py # CRUD, tag filter, get_or_create_by_title, convert_note_to_task, get_backlinks, cascade delete/sync │ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks
│ │ └── tasks.py # CRUD with auto companion note, cascade delete/sync, _skip_cascade flag │ │ └── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
│ ├── utils/ │ ├── utils/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences │ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
@@ -155,12 +168,12 @@ fabledassistant/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme │ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search │ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
│ ├── stores/ │ ├── stores/
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, fetchBacklinks, fetchAllTags │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus │ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss │ │ └── toast.ts # Toast notification state, 3s auto-dismiss
│ ├── types/ │ ├── types/
│ │ ├── note.ts # Note, NoteListResponse interfaces │ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
│ │ └── task.ts # Task, TaskStatus, TaskPriority, TaskListResponse │ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── utils/ │ ├── utils/
│ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks() │ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
│ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards) │ │ └── 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) │ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination │ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete │ │ ├── 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 │ │ ├── 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 │ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, companion note link, backlinks │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
│ ├── components/ │ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle │ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit │ │ ├── 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 │ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none") │ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
│ │ ├── MarkdownToolbar.vue # Bold/italic/link/list/heading toolbar for editor │ │ ├── MarkdownToolbar.vue # Bold/italic/link/list/heading toolbar for editor
@@ -193,22 +206,23 @@ fabledassistant/
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/health` | Health check | | GET | `/api/health` | Health check |
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`) | | 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}` — tags auto-extracted) | | POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) |
| GET | `/api/notes/tags` | List all tags from notes + tasks (param: `q` for filter) | | 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) | | 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) | | POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) |
| GET | `/api/notes/:id` | Get single 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?}` — tags re-extracted if body changes) | | 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 (cascades to companion task) | | DELETE | `/api/notes/:id` | Delete note (simple delete, no cascade) |
| POST | `/api/notes/:id/convert-to-task` | Convert note into a task (deletes original note) | | 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/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`) | | 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, description, status?, priority?, due_date?}`companion note auto-created) | | 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 | | 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}`) | | 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 ## Alembic Migrations
@@ -219,7 +233,7 @@ container startup.
### Migration Chain ### 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 ### How Migrations Run
@@ -236,15 +250,15 @@ When adding a new migration, follow these conventions:
1. **Create the migration file:** 1. **Create the migration file:**
``` ```
alembic/versions/0004_description.py alembic/versions/0005_description.py
``` ```
2. **Use raw SQL for idempotency:** 2. **Use raw SQL for idempotency:**
```python ```python
from alembic import op from alembic import op
revision = "0004" revision = "0005"
down_revision = "0003" down_revision = "0004"
def upgrade() -> None: def upgrade() -> None:
# For new enums: # For new enums:
@@ -321,21 +335,14 @@ When adding a new migration, follow these conventions:
### Phase 3 — Tasks CRUD + Wikilinks ✓ ### Phase 3 — Tasks CRUD + Wikilinks ✓
- [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums - [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] 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] REST API: full CRUD + PATCH status toggle + filter by status/priority/tags
- [x] Task-Note linking via optional `note_id` FK - [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (Ctrl+S, dirty guard), viewer (rendered markdown)
- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (note-link autocomplete, Ctrl+S, dirty guard), viewer (rendered markdown)
- [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components - [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components
- [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown - [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] 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 ✓ ### 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] **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] **Backlinks system:** "What links here" section on note and task viewers
- [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped) - [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 - [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] **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] **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] **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) ### Phase 4 — LLM Integration (next)
- [ ] Ollama client in the backend (async HTTP via httpx/aiohttp) - [ ] 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? - Should LLM streaming use WebSockets from Phase 4 or defer to Phase 5?
## Current Status ## Current Status
**Phase:** Phase 3.5 complete. Note-task companion system, wikilink auto-create, **Phase:** Phase 3.6 complete. Tasks merged into notes — unified single-table model.
backlinks, and bug fixes fully implemented. - 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 notes CRUD with markdown editing, rendering, and wikilinks
- Full tasks CRUD with status/priority enums, filters, companion note linking - Full tasks CRUD with status/priority enums and filters
- Automatic companion note creation for every task - Backlinks, wikilink auto-create, tag autocomplete all work across unified model
- 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
- Dark/light theme with status/priority/wikilink color variables - Dark/light theme with status/priority/wikilink color variables
- Ready for Phase 4: LLM Integration - Ready for Phase 4: LLM Integration