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:
@@ -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")
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,5 +11,4 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
from fabledassistant.models.note import Note # noqa: E402, F401
|
||||
from fabledassistant.models.task import Task # noqa: E402, F401
|
||||
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
from datetime import datetime, timezone
|
||||
import enum
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Text
|
||||
from sqlalchemy import Date, DateTime, Index, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
done = "done"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
none = "none"
|
||||
low = "low"
|
||||
medium = "medium"
|
||||
high = "high"
|
||||
|
||||
|
||||
class Note(Base):
|
||||
__tablename__ = "notes"
|
||||
|
||||
@@ -15,8 +29,11 @@ class Note(Base):
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
nullable=True
|
||||
)
|
||||
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -26,7 +43,14 @@ class Note(Base):
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
__table_args__ = (Index("ix_notes_tags", "tags", postgresql_using="gin"),)
|
||||
__table_args__ = (
|
||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||
Index("ix_notes_status", "status"),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_task(self) -> bool:
|
||||
return self.status is not None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -35,6 +59,10 @@ class Note(Base):
|
||||
"body": self.body,
|
||||
"tags": self.tags or [],
|
||||
"parent_id": self.parent_id,
|
||||
"status": self.status,
|
||||
"priority": self.priority,
|
||||
"due_date": self.due_date.isoformat() if self.due_date else None,
|
||||
"is_task": self.is_task,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import enum
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Index, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
done = "done"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
none = "none"
|
||||
low = "low"
|
||||
medium = "medium"
|
||||
high = "high"
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
Enum(TaskStatus, name="task_status", create_constraint=False),
|
||||
default=TaskStatus.todo,
|
||||
)
|
||||
priority: Mapped[TaskPriority] = mapped_column(
|
||||
Enum(TaskPriority, name="task_priority", create_constraint=False),
|
||||
default=TaskPriority.none,
|
||||
)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
note_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_tasks_tags", "tags", postgresql_using="gin"),
|
||||
Index("ix_tasks_note_id", "note_id"),
|
||||
Index("ix_tasks_status", "status"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"status": self.status.value,
|
||||
"priority": self.priority.value,
|
||||
"due_date": self.due_date.isoformat() if self.due_date else None,
|
||||
"note_id": self.note_id,
|
||||
"tags": self.tags or [],
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.services.notes import (
|
||||
convert_note_to_task,
|
||||
convert_task_to_note,
|
||||
create_note,
|
||||
delete_note,
|
||||
get_all_tags,
|
||||
@@ -26,8 +29,15 @@ async def list_notes_route():
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
# Default to non-task notes only; ?is_task=true for tasks, ?all=true for everything
|
||||
is_task: bool | None = False
|
||||
if request.args.get("all", "").lower() == "true":
|
||||
is_task = None
|
||||
elif request.args.get("is_task", "").lower() == "true":
|
||||
is_task = True
|
||||
|
||||
notes, total = await list_notes(
|
||||
q=q, tags=tag or None, sort=sort, order=order, limit=limit, offset=offset
|
||||
q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, limit=limit, offset=offset
|
||||
)
|
||||
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
|
||||
|
||||
@@ -37,11 +47,22 @@ async def create_note_route():
|
||||
data = await request.get_json()
|
||||
body = data.get("body", "")
|
||||
tags = extract_tags(body)
|
||||
|
||||
# Optional task fields
|
||||
status = data.get("status")
|
||||
priority = data.get("priority")
|
||||
due_date = None
|
||||
if data.get("due_date"):
|
||||
due_date = date.fromisoformat(data["due_date"])
|
||||
|
||||
note = await create_note(
|
||||
title=data.get("title", ""),
|
||||
body=body,
|
||||
tags=tags,
|
||||
parent_id=data.get("parent_id"),
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
)
|
||||
return jsonify(note.to_dict()), 201
|
||||
|
||||
@@ -86,9 +107,15 @@ async def get_note_route(note_id: int):
|
||||
async def update_note_route(note_id: int):
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "parent_id"):
|
||||
for key in ("title", "body", "parent_id", "status", "priority"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
|
||||
if "due_date" in data:
|
||||
fields["due_date"] = (
|
||||
date.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
)
|
||||
|
||||
if "body" in fields:
|
||||
fields["tags"] = extract_tags(fields["body"])
|
||||
note = await update_note(note_id, **fields)
|
||||
@@ -108,8 +135,17 @@ async def delete_note_route(note_id: int):
|
||||
@notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
|
||||
async def convert_note_to_task_route(note_id: int):
|
||||
try:
|
||||
task = await convert_note_to_task(note_id)
|
||||
return jsonify(task.to_dict()), 201
|
||||
note = await convert_note_to_task(note_id)
|
||||
return jsonify(note.to_dict()), 200
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"])
|
||||
async def convert_task_to_note_route(note_id: int):
|
||||
try:
|
||||
note = await convert_task_to_note(note_id)
|
||||
return jsonify(note.to_dict()), 200
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import date
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.models.task import TaskPriority, TaskStatus
|
||||
from fabledassistant.models.note import TaskPriority, TaskStatus
|
||||
from fabledassistant.services.tasks import (
|
||||
create_task,
|
||||
delete_task,
|
||||
@@ -21,7 +21,6 @@ async def list_tasks_route():
|
||||
tag = request.args.getlist("tag")
|
||||
status = request.args.get("status")
|
||||
priority = request.args.get("priority")
|
||||
note_id = request.args.get("note_id", type=int)
|
||||
sort = request.args.get("sort", "updated_at")
|
||||
order = request.args.get("order", "desc")
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
@@ -32,7 +31,6 @@ async def list_tasks_route():
|
||||
tags=tag or None,
|
||||
status=status,
|
||||
priority=priority,
|
||||
note_id=note_id,
|
||||
sort=sort,
|
||||
order=order,
|
||||
limit=limit,
|
||||
@@ -44,21 +42,21 @@ async def list_tasks_route():
|
||||
@tasks_bp.route("", methods=["POST"])
|
||||
async def create_task_route():
|
||||
data = await request.get_json()
|
||||
description = data.get("description", "")
|
||||
tags = extract_tags(description)
|
||||
body = data.get("body", "") or data.get("description", "")
|
||||
tags = extract_tags(body)
|
||||
|
||||
due_date = None
|
||||
if data.get("due_date"):
|
||||
due_date = date.fromisoformat(data["due_date"])
|
||||
|
||||
status = TaskStatus(data["status"]) if "status" in data else TaskStatus.todo
|
||||
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
|
||||
priority = (
|
||||
TaskPriority(data["priority"]) if "priority" in data else TaskPriority.none
|
||||
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
|
||||
)
|
||||
|
||||
task = await create_task(
|
||||
title=data.get("title", ""),
|
||||
description=description,
|
||||
body=body,
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
@@ -79,17 +77,23 @@ async def get_task_route(task_id: int):
|
||||
async def update_task_route(task_id: int):
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "description", "status", "priority"):
|
||||
for key in ("title", "status", "priority"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
|
||||
# Accept both "body" and "description" (prefer body)
|
||||
if "body" in data:
|
||||
fields["body"] = data["body"]
|
||||
elif "description" in data:
|
||||
fields["body"] = data["description"]
|
||||
|
||||
if "due_date" in data:
|
||||
fields["due_date"] = (
|
||||
date.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
)
|
||||
|
||||
if "description" in fields:
|
||||
fields["tags"] = extract_tags(fields["description"])
|
||||
if "body" in fields:
|
||||
fields["tags"] = extract_tags(fields["body"])
|
||||
|
||||
task = await update_task(task_id, **fields)
|
||||
if task is None:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.task import Task
|
||||
from fabledassistant.models.note import Note, TaskPriority, TaskStatus
|
||||
|
||||
|
||||
async def create_note(
|
||||
@@ -12,6 +11,9 @@ async def create_note(
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
parent_id: int | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
due_date: date | None = None,
|
||||
) -> Note:
|
||||
async with async_session() as session:
|
||||
note = Note(
|
||||
@@ -19,6 +21,9 @@ async def create_note(
|
||||
body=body,
|
||||
tags=tags or [],
|
||||
parent_id=parent_id,
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
)
|
||||
session.add(note)
|
||||
await session.commit()
|
||||
@@ -34,6 +39,9 @@ async def get_note(note_id: int) -> Note | None:
|
||||
async def list_notes(
|
||||
q: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
is_task: bool | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
sort: str = "updated_at",
|
||||
order: str = "desc",
|
||||
limit: int = 50,
|
||||
@@ -43,6 +51,14 @@ async def list_notes(
|
||||
query = select(Note)
|
||||
count_query = select(func.count(Note.id))
|
||||
|
||||
# Filter by task vs note
|
||||
if is_task is True:
|
||||
query = query.where(Note.status.isnot(None))
|
||||
count_query = count_query.where(Note.status.isnot(None))
|
||||
elif is_task is False:
|
||||
query = query.where(Note.status.is_(None))
|
||||
count_query = count_query.where(Note.status.is_(None))
|
||||
|
||||
if q:
|
||||
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped_q}%"
|
||||
@@ -61,6 +77,14 @@ async def list_notes(
|
||||
query = query.where(tag_filter)
|
||||
count_query = count_query.where(tag_filter)
|
||||
|
||||
if status:
|
||||
query = query.where(Note.status == status)
|
||||
count_query = count_query.where(Note.status == status)
|
||||
|
||||
if priority:
|
||||
query = query.where(Note.priority == priority)
|
||||
count_query = count_query.where(Note.priority == priority)
|
||||
|
||||
sort_col = getattr(Note, sort, Note.updated_at)
|
||||
if order == "asc":
|
||||
query = query.order_by(sort_col.asc())
|
||||
@@ -91,46 +115,30 @@ async def get_or_create_note_by_title(title: str) -> Note:
|
||||
return await create_note(title=title)
|
||||
|
||||
|
||||
async def update_note(
|
||||
note_id: int, _skip_cascade: bool = False, **fields: object
|
||||
) -> Note | None:
|
||||
async def update_note(note_id: int, **fields: object) -> Note | None:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
if note is None:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
if hasattr(note, key):
|
||||
setattr(note, key, value)
|
||||
if not hasattr(note, key):
|
||||
continue
|
||||
if key == "status" and isinstance(value, str):
|
||||
value = TaskStatus(value).value
|
||||
elif key == "priority" and isinstance(value, str):
|
||||
value = TaskPriority(value).value
|
||||
setattr(note, key, value)
|
||||
note.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Sync title to companion task
|
||||
if not _skip_cascade and "title" in fields:
|
||||
result = await session.execute(
|
||||
select(Task).where(Task.note_id == note_id).limit(1)
|
||||
)
|
||||
companion_task = result.scalars().first()
|
||||
if companion_task:
|
||||
companion_task.title = note.title
|
||||
companion_task.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def delete_note(note_id: int, _skip_cascade: bool = False) -> bool:
|
||||
async def delete_note(note_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
if note is None:
|
||||
return False
|
||||
# Cascade delete companion task
|
||||
if not _skip_cascade:
|
||||
result = await session.execute(
|
||||
select(Task).where(Task.note_id == note_id)
|
||||
)
|
||||
for companion_task in result.scalars().all():
|
||||
companion_task.note_id = None # unlink to avoid FK issues
|
||||
await session.delete(companion_task)
|
||||
await session.delete(note)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -140,11 +148,10 @@ async def get_all_tags(q: str | None = None) -> list[str]:
|
||||
async with async_session() as session:
|
||||
all_tags: set[str] = set()
|
||||
|
||||
for Model in (Note, Task):
|
||||
result = await session.execute(select(Model))
|
||||
for obj in result.scalars():
|
||||
if obj.tags:
|
||||
all_tags.update(obj.tags)
|
||||
result = await session.execute(select(Note))
|
||||
for obj in result.scalars():
|
||||
if obj.tags:
|
||||
all_tags.update(obj.tags)
|
||||
|
||||
if q:
|
||||
q_lower = q.lower()
|
||||
@@ -153,24 +160,20 @@ async def get_all_tags(q: str | None = None) -> list[str]:
|
||||
return sorted(all_tags)
|
||||
|
||||
|
||||
async def convert_note_to_task(note_id: int) -> Task:
|
||||
from fabledassistant.services.tasks import create_task
|
||||
|
||||
async def convert_note_to_task(note_id: int) -> Note:
|
||||
note = await get_note(note_id)
|
||||
if note is None:
|
||||
raise ValueError("Note not found")
|
||||
updated = await update_note(note_id, status=TaskStatus.todo.value, priority=TaskPriority.none.value)
|
||||
return updated
|
||||
|
||||
# Create a new task (this auto-creates a companion note)
|
||||
task = await create_task(title=note.title, tags=list(note.tags))
|
||||
|
||||
# Copy the original note's body to the companion note
|
||||
if task.note_id:
|
||||
await update_note(task.note_id, _skip_cascade=True, body=note.body, tags=list(note.tags))
|
||||
|
||||
# Delete the original note (skip cascade since we're managing this)
|
||||
await delete_note(note_id, _skip_cascade=True)
|
||||
|
||||
return task
|
||||
async def convert_task_to_note(note_id: int) -> Note:
|
||||
note = await get_note(note_id)
|
||||
if note is None:
|
||||
raise ValueError("Note not found")
|
||||
updated = await update_note(note_id, status=None, priority=None, due_date=None)
|
||||
return updated
|
||||
|
||||
|
||||
async def get_backlinks(note_id: int) -> list[dict]:
|
||||
@@ -183,33 +186,19 @@ async def get_backlinks(note_id: int) -> list[dict]:
|
||||
return []
|
||||
|
||||
async with async_session() as session:
|
||||
# Escape SQL LIKE wildcards in title
|
||||
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
# Search for [[Title]] or [[Title|...]] in note bodies
|
||||
pattern = f"%[[{escaped}]]%"
|
||||
pattern_alias = f"%[[{escaped}|%"
|
||||
|
||||
# Find notes with backlinks (exclude this note itself)
|
||||
note_results = await session.execute(
|
||||
select(Note.id, Note.title).where(
|
||||
results = await session.execute(
|
||||
select(Note.id, Note.title, Note.status).where(
|
||||
Note.id != note_id,
|
||||
or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
|
||||
)
|
||||
)
|
||||
backlinks: list[dict] = []
|
||||
for row in note_results.fetchall():
|
||||
backlinks.append({"type": "note", "id": row[0], "title": row[1]})
|
||||
|
||||
# Find tasks with backlinks
|
||||
task_results = await session.execute(
|
||||
select(Task.id, Task.title).where(
|
||||
or_(
|
||||
Task.description.like(pattern),
|
||||
Task.description.like(pattern_alias),
|
||||
)
|
||||
)
|
||||
)
|
||||
for row in task_results.fetchall():
|
||||
backlinks.append({"type": "task", "id": row[0], "title": row[1]})
|
||||
for row in results.fetchall():
|
||||
link_type = "task" if row[2] is not None else "note"
|
||||
backlinks.append({"type": link_type, "id": row[0], "title": row[1]})
|
||||
|
||||
return backlinks
|
||||
|
||||
@@ -1,44 +1,35 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.task import Task, TaskPriority, TaskStatus
|
||||
from fabledassistant.models.note import Note, TaskPriority, TaskStatus
|
||||
from fabledassistant.services.notes import (
|
||||
create_note,
|
||||
delete_note,
|
||||
get_note,
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
|
||||
|
||||
async def create_task(
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
status: TaskStatus = TaskStatus.todo,
|
||||
priority: TaskPriority = TaskPriority.none,
|
||||
due_date=None,
|
||||
body: str = "",
|
||||
status: str = TaskStatus.todo.value,
|
||||
priority: str = TaskPriority.none.value,
|
||||
due_date: date | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> Task:
|
||||
async with async_session() as session:
|
||||
# Auto-create companion note
|
||||
companion = Note(title=title, body="", tags=tags or [])
|
||||
session.add(companion)
|
||||
await session.flush()
|
||||
|
||||
task = Task(
|
||||
title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
note_id=companion.id,
|
||||
tags=tags or [],
|
||||
)
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
) -> Note:
|
||||
return await create_note(
|
||||
title=title,
|
||||
body=body,
|
||||
tags=tags,
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
)
|
||||
|
||||
|
||||
async def get_task(task_id: int) -> Task | None:
|
||||
async with async_session() as session:
|
||||
return await session.get(Task, task_id)
|
||||
async def get_task(task_id: int) -> Note | None:
|
||||
return await get_note(task_id)
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
@@ -46,100 +37,27 @@ async def list_tasks(
|
||||
tags: list[str] | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
note_id: int | None = None,
|
||||
sort: str = "updated_at",
|
||||
order: str = "desc",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Task], int]:
|
||||
async with async_session() as session:
|
||||
query = select(Task)
|
||||
count_query = select(func.count(Task.id))
|
||||
|
||||
if q:
|
||||
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped_q}%"
|
||||
filter_ = or_(Task.title.ilike(pattern), Task.description.ilike(pattern))
|
||||
query = query.where(filter_)
|
||||
count_query = count_query.where(filter_)
|
||||
|
||||
if tags:
|
||||
for i, tag in enumerate(tags):
|
||||
param_tag = f"tag_{i}"
|
||||
param_prefix = f"tag_prefix_{i}"
|
||||
tag_filter = text(
|
||||
f"EXISTS (SELECT 1 FROM unnest(tasks.tags) AS t"
|
||||
f" WHERE t = :{param_tag} OR t LIKE :{param_prefix})"
|
||||
).bindparams(**{param_tag: tag, param_prefix: tag + "/%"})
|
||||
query = query.where(tag_filter)
|
||||
count_query = count_query.where(tag_filter)
|
||||
|
||||
if status:
|
||||
query = query.where(Task.status == TaskStatus(status))
|
||||
count_query = count_query.where(Task.status == TaskStatus(status))
|
||||
|
||||
if priority:
|
||||
query = query.where(Task.priority == TaskPriority(priority))
|
||||
count_query = count_query.where(Task.priority == TaskPriority(priority))
|
||||
|
||||
if note_id is not None:
|
||||
query = query.where(Task.note_id == note_id)
|
||||
count_query = count_query.where(Task.note_id == note_id)
|
||||
|
||||
sort_col = getattr(Task, sort, Task.updated_at)
|
||||
if order == "asc":
|
||||
query = query.order_by(sort_col.asc())
|
||||
else:
|
||||
query = query.order_by(sort_col.desc())
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
total = await session.scalar(count_query) or 0
|
||||
result = await session.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
return tasks, total
|
||||
) -> tuple[list[Note], int]:
|
||||
return await list_notes(
|
||||
q=q,
|
||||
tags=tags,
|
||||
is_task=True,
|
||||
status=status,
|
||||
priority=priority,
|
||||
sort=sort,
|
||||
order=order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
async def update_task(
|
||||
task_id: int, _skip_cascade: bool = False, **fields: object
|
||||
) -> Task | None:
|
||||
async with async_session() as session:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
if not hasattr(task, key):
|
||||
continue
|
||||
if key == "status" and isinstance(value, str):
|
||||
value = TaskStatus(value)
|
||||
elif key == "priority" and isinstance(value, str):
|
||||
value = TaskPriority(value)
|
||||
setattr(task, key, value)
|
||||
task.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Sync title to companion note
|
||||
if not _skip_cascade and "title" in fields and task.note_id:
|
||||
companion = await session.get(Note, task.note_id)
|
||||
if companion:
|
||||
companion.title = task.title
|
||||
companion.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
async def update_task(task_id: int, **fields: object) -> Note | None:
|
||||
return await update_note(task_id, **fields)
|
||||
|
||||
|
||||
async def delete_task(task_id: int, _skip_cascade: bool = False) -> bool:
|
||||
async with async_session() as session:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
return False
|
||||
companion_note_id = task.note_id
|
||||
await session.delete(task)
|
||||
# Cascade delete companion note
|
||||
if not _skip_cascade and companion_note_id:
|
||||
companion = await session.get(Note, companion_note_id)
|
||||
if companion:
|
||||
await session.delete(companion)
|
||||
await session.commit()
|
||||
return True
|
||||
async def delete_task(task_id: int) -> bool:
|
||||
return await delete_note(task_id)
|
||||
|
||||
+86
-70
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user