M2 checklists: note kind + items backend + editor/card UI
- Migration 0006: notes.kind ('text'|'list') + note_items (text, checked,
position). Item API: add/update(toggle)/delete/reorder; PATCH note kind; note
responses include kind + items[] (merged in one query alongside labels).
- notes store: kind/items on Note, setKind/addItem/updateItem/deleteItem.
- NoteChecklist component (toggle/add/edit/delete items); rendered read-only-ish
on cards (checkboxes toggle) and editable in the editor.
- Editor: convert text<->checklist (body lines become items on convert to list).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
"""checklists: notes.kind + note_items
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-07-20
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0006"
|
||||||
|
down_revision = "0005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
|
||||||
|
op.create_table(
|
||||||
|
"note_items",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("checked", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_note_items_note", "note_items", ["note_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_note_items_note", table_name="note_items")
|
||||||
|
op.drop_table("note_items")
|
||||||
|
op.drop_column("notes", "kind")
|
||||||
@@ -14,6 +14,7 @@ const paths: Record<string, string> = {
|
|||||||
pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
|
pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
|
||||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
|
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNotesStore } from "../stores/notes";
|
|||||||
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
|
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
import type { Note } from "../stores/notes";
|
import type { Note } from "../stores/notes";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
|
import NoteChecklist from "./NoteChecklist.vue";
|
||||||
|
|
||||||
defineProps<{ note: Note }>();
|
defineProps<{ note: Note }>();
|
||||||
const emit = defineEmits<{ (e: "open", note: Note): void }>();
|
const emit = defineEmits<{ (e: "open", note: Note): void }>();
|
||||||
@@ -18,9 +19,27 @@ function cardClass(color: NoteColor): string {
|
|||||||
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
|
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
|
||||||
:class="cardClass(note.color)"
|
:class="cardClass(note.color)"
|
||||||
>
|
>
|
||||||
|
<!-- Checklist notes can't nest interactive controls in a <button>, so use a
|
||||||
|
focusable div; text notes keep a semantic button. -->
|
||||||
|
<template v-if="note.kind === 'list'">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||||
|
@click="emit('open', note)"
|
||||||
|
@keydown.enter="emit('open', note)"
|
||||||
|
>
|
||||||
|
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{{ note.title }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<NoteChecklist class="mt-1" :note-id="note.id" :items="note.items" @click="emit('open', note)" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full cursor-text text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent rounded"
|
class="block w-full cursor-text rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
|
||||||
@click="emit('open', note)"
|
@click="emit('open', note)"
|
||||||
>
|
>
|
||||||
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useNotesStore, type ChecklistItem } from "../stores/notes";
|
||||||
|
|
||||||
|
const props = defineProps<{ noteId: string; items: ChecklistItem[]; editable?: boolean }>();
|
||||||
|
const notes = useNotesStore();
|
||||||
|
const newItem = ref("");
|
||||||
|
|
||||||
|
async function addItem() {
|
||||||
|
const text = newItem.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
await notes.addItem(props.noteId, text);
|
||||||
|
newItem.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(item: ChecklistItem) {
|
||||||
|
void notes.updateItem(props.noteId, item.id, { checked: !item.checked });
|
||||||
|
}
|
||||||
|
|
||||||
|
function editText(item: ChecklistItem, value: string) {
|
||||||
|
if (value !== item.text) void notes.updateItem(props.noteId, item.id, { text: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(item: ChecklistItem) {
|
||||||
|
void notes.deleteItem(props.noteId, item.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div v-for="item in items" :key="item.id" class="group/item flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 shrink-0 accent-brand"
|
||||||
|
:checked="item.checked"
|
||||||
|
@change="toggle(item)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-if="editable"
|
||||||
|
:value="item.text"
|
||||||
|
class="min-w-0 flex-1 bg-transparent text-sm outline-none"
|
||||||
|
:class="item.checked ? 'text-neutral-400 line-through' : ''"
|
||||||
|
@change="editText(item, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="min-w-0 flex-1 truncate text-sm"
|
||||||
|
:class="item.checked ? 'text-neutral-400 line-through' : 'text-neutral-700 dark:text-neutral-300'"
|
||||||
|
>{{ item.text }}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="editable"
|
||||||
|
type="button"
|
||||||
|
class="text-neutral-300 opacity-0 hover:text-neutral-600 group-hover/item:opacity-100 dark:hover:text-neutral-200"
|
||||||
|
aria-label="Delete item"
|
||||||
|
@click="remove(item)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-if="editable" class="mt-1 flex items-center gap-2" @submit.prevent="addItem">
|
||||||
|
<span class="h-4 w-4 shrink-0" />
|
||||||
|
<input
|
||||||
|
v-model="newItem"
|
||||||
|
type="text"
|
||||||
|
placeholder="+ List item"
|
||||||
|
class="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="!editable && items.length === 0" class="text-sm italic text-neutral-400">Empty checklist</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onMounted, ref, watch } from "vue";
|
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
import ColorPicker from "./ColorPicker.vue";
|
import ColorPicker from "./ColorPicker.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LabelPicker from "./LabelPicker.vue";
|
import LabelPicker from "./LabelPicker.vue";
|
||||||
|
import NoteChecklist from "./NoteChecklist.vue";
|
||||||
import type { Note, NoteLabel } from "../stores/notes";
|
import type { Note, NoteLabel } from "../stores/notes";
|
||||||
import type { NoteColor } from "../notes/colors";
|
import type { NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
@@ -11,6 +12,10 @@ const props = defineProps<{ note: Note }>();
|
|||||||
const emit = defineEmits<{ (e: "close"): void }>();
|
const emit = defineEmits<{ (e: "close"): void }>();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
|
|
||||||
|
// Read the note reactively from the store so checklist item add/toggle/delete
|
||||||
|
// (which reconcile a fresh note object) reflect live while the editor is open.
|
||||||
|
const liveNote = computed(() => notes.items.find((n) => n.id === props.note.id) ?? props.note);
|
||||||
|
|
||||||
const title = ref(props.note.title ?? "");
|
const title = ref(props.note.title ?? "");
|
||||||
const body = ref(props.note.body);
|
const body = ref(props.note.body);
|
||||||
const color = ref<NoteColor>(props.note.color);
|
const color = ref<NoteColor>(props.note.color);
|
||||||
@@ -44,6 +49,24 @@ async function removeLabel(id: string) {
|
|||||||
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
|
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleKind() {
|
||||||
|
if (liveNote.value.kind === "list") {
|
||||||
|
await notes.setKind(props.note.id, "text");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Convert existing body lines into checklist items, then switch to a list.
|
||||||
|
const lines = body.value
|
||||||
|
.split("\n")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
for (const line of lines) await notes.addItem(props.note.id, line);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
body.value = "";
|
||||||
|
await notes.saveEdit(props.note.id, { title: title.value, body: "", color: color.value });
|
||||||
|
}
|
||||||
|
await notes.setKind(props.note.id, "list");
|
||||||
|
}
|
||||||
|
|
||||||
async function close() {
|
async function close() {
|
||||||
const changed =
|
const changed =
|
||||||
(title.value.trim() || null) !== (props.note.title ?? null) ||
|
(title.value.trim() || null) !== (props.note.title ?? null) ||
|
||||||
@@ -80,12 +103,15 @@ async function act(fn: () => Promise<void>) {
|
|||||||
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
|
v-if="liveNote.kind === 'text'"
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
rows="8"
|
rows="8"
|
||||||
placeholder="Take a note…"
|
placeholder="Take a note…"
|
||||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
|
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
|
||||||
|
|
||||||
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
||||||
<span
|
<span
|
||||||
v-for="lb in labelList"
|
v-for="lb in labelList"
|
||||||
@@ -104,9 +130,20 @@ async function act(fn: () => Promise<void>) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
|
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
|
||||||
<ColorPicker v-model="color" />
|
<ColorPicker v-model="color" />
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
v-if="!note.trashed"
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
:class="liveNote.kind === 'list' ? 'text-brand-700 dark:text-brand' : ''"
|
||||||
|
:title="liveNote.kind === 'list' ? 'Convert to text note' : 'Convert to checklist'"
|
||||||
|
@click="toggleKind"
|
||||||
|
>
|
||||||
|
<Icon name="checkbox" />
|
||||||
|
</button>
|
||||||
<LabelPicker v-if="!note.trashed" :model-value="labelList" @update:model-value="onLabelsChange" />
|
<LabelPicker v-if="!note.trashed" :model-value="labelList" @update:model-value="onLabelsChange" />
|
||||||
<template v-if="!note.trashed">
|
<template v-if="!note.trashed">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,21 +4,31 @@ import { api } from "../api/client";
|
|||||||
import type { NoteColor } from "../notes/colors";
|
import type { NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
export type NoteView = "active" | "archived" | "trash";
|
export type NoteView = "active" | "archived" | "trash";
|
||||||
|
export type NoteKind = "text" | "list";
|
||||||
|
|
||||||
export interface NoteLabel {
|
export interface NoteLabel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChecklistItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
checked: boolean;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
body: string;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
|
kind: NoteKind;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
trashed: boolean;
|
trashed: boolean;
|
||||||
labels: NoteLabel[];
|
labels: NoteLabel[];
|
||||||
|
items: ChecklistItem[];
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -76,7 +86,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function mutate(
|
async function mutate(
|
||||||
id: string,
|
id: string,
|
||||||
changes: Partial<Pick<Note, "title" | "body" | "color" | "pinned" | "archived">>,
|
changes: Partial<Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived">>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
reconcile(await api.patch<Note>(`/api/notes/${id}`, changes));
|
reconcile(await api.patch<Note>(`/api/notes/${id}`, changes));
|
||||||
}
|
}
|
||||||
@@ -84,12 +94,25 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
|
const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned });
|
||||||
const setArchived = (id: string, archived: boolean) => mutate(id, { archived });
|
const setArchived = (id: string, archived: boolean) => mutate(id, { archived });
|
||||||
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
||||||
|
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
|
||||||
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
|
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
|
||||||
|
|
||||||
async function setLabels(id: string, labelIds: string[]): Promise<void> {
|
async function setLabels(id: string, labelIds: string[]): Promise<void> {
|
||||||
reconcile(await api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }));
|
reconcile(await api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addItem(id: string, text: string): Promise<void> {
|
||||||
|
reconcile(await api.post<Note>(`/api/notes/${id}/items`, { text }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise<void> {
|
||||||
|
reconcile(await api.patch<Note>(`/api/notes/${id}/items/${itemId}`, changes));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(id: string, itemId: string): Promise<void> {
|
||||||
|
reconcile(await api.del<Note>(`/api/notes/${id}/items/${itemId}`));
|
||||||
|
}
|
||||||
|
|
||||||
async function trash(id: string): Promise<void> {
|
async function trash(id: string): Promise<void> {
|
||||||
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
|
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
|
||||||
}
|
}
|
||||||
@@ -114,8 +137,12 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
setPinned,
|
setPinned,
|
||||||
setArchived,
|
setArchived,
|
||||||
setColor,
|
setColor,
|
||||||
|
setKind,
|
||||||
saveEdit,
|
saveEdit,
|
||||||
setLabels,
|
setLabels,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
trash,
|
trash,
|
||||||
restore,
|
restore,
|
||||||
deleteForever,
|
deleteForever,
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
Imported for side effects only (model registration on Base.metadata).
|
Imported for side effects only (model registration on Base.metadata).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import group, label, note, settings, share, user # noqa: F401
|
from . import group, label, note, note_item, settings, share, user # noqa: F401
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class Note(Base):
|
|||||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||||
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||||
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
||||||
|
# 'text' (freeform body) or 'list' (a checklist of note_items).
|
||||||
|
kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text")
|
||||||
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||||
archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||||
# Soft delete: non-null => in Trash. Restore sets it back to null.
|
# Soft delete: non-null => in Trash. Restore sets it back to null.
|
||||||
@@ -55,6 +57,7 @@ class Note(Base):
|
|||||||
"title": self.title,
|
"title": self.title,
|
||||||
"body": self.body,
|
"body": self.body,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
|
"kind": self.kind,
|
||||||
"pinned": self.pinned,
|
"pinned": self.pinned,
|
||||||
"archived": self.archived,
|
"archived": self.archived,
|
||||||
"trashed": self.deleted_at is not None,
|
"trashed": self.deleted_at is not None,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from . import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NoteItem(Base):
|
||||||
|
"""A single checklist item within a note (only used when note.kind == 'list')."""
|
||||||
|
|
||||||
|
__tablename__ = "note_items"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
note_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
text: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||||
|
checked: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||||
|
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
+122
-14
@@ -11,6 +11,7 @@ from .auth import login_required
|
|||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
from .models.label import Label, NoteLabel
|
from .models.label import Label, NoteLabel
|
||||||
from .models.note import NOTE_COLORS, Note
|
from .models.note import NOTE_COLORS, Note
|
||||||
|
from .models.note_item import NoteItem
|
||||||
|
|
||||||
bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
||||||
|
|
||||||
@@ -50,13 +51,47 @@ async def _labels_for_notes(db, note_ids: list) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_item(item: NoteItem) -> dict:
|
||||||
|
return {"id": str(item.id), "text": item.text, "checked": item.checked, "position": item.position}
|
||||||
|
|
||||||
|
|
||||||
|
async def _items_for_notes(db, note_ids: list) -> dict:
|
||||||
|
"""Map note_id -> [checklist items] in one query, ordered by position."""
|
||||||
|
result: dict = {}
|
||||||
|
if not note_ids:
|
||||||
|
return result
|
||||||
|
items = (
|
||||||
|
await db.scalars(
|
||||||
|
select(NoteItem).where(NoteItem.note_id.in_(note_ids)).order_by(NoteItem.position, NoteItem.created_at)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for item in items:
|
||||||
|
result.setdefault(item.note_id, []).append(_serialize_item(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _serialize_note(db, note: Note) -> dict:
|
async def _serialize_note(db, note: Note) -> dict:
|
||||||
data = note.serialize()
|
data = note.serialize()
|
||||||
labels = await _labels_for_notes(db, [note.id])
|
labels = await _labels_for_notes(db, [note.id])
|
||||||
data["labels"] = labels.get(note.id, [])
|
data["labels"] = labels.get(note.id, [])
|
||||||
|
items = await _items_for_notes(db, [note.id])
|
||||||
|
data["items"] = items.get(note.id, [])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def _serialize_notes(db, notes: list) -> list:
|
||||||
|
ids = [n.id for n in notes]
|
||||||
|
labels_map = await _labels_for_notes(db, ids)
|
||||||
|
items_map = await _items_for_notes(db, ids)
|
||||||
|
out = []
|
||||||
|
for n in notes:
|
||||||
|
data = n.serialize()
|
||||||
|
data["labels"] = labels_map.get(n.id, [])
|
||||||
|
data["items"] = items_map.get(n.id, [])
|
||||||
|
out.append(data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned(db, note_id: str) -> Note | None:
|
async def _get_owned(db, note_id: str) -> Note | None:
|
||||||
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
|
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
|
||||||
try:
|
try:
|
||||||
@@ -84,13 +119,7 @@ async def list_notes():
|
|||||||
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
|
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
|
||||||
stmt = stmt.order_by(Note.pinned.desc(), Note.updated_at.desc())
|
stmt = stmt.order_by(Note.pinned.desc(), Note.updated_at.desc())
|
||||||
notes = (await db.scalars(stmt)).all()
|
notes = (await db.scalars(stmt)).all()
|
||||||
labels_map = await _labels_for_notes(db, [n.id for n in notes])
|
return jsonify({"notes": await _serialize_notes(db, notes)})
|
||||||
out = []
|
|
||||||
for n in notes:
|
|
||||||
data = n.serialize()
|
|
||||||
data["labels"] = labels_map.get(n.id, [])
|
|
||||||
out.append(data)
|
|
||||||
return jsonify({"notes": out})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/search")
|
@bp.get("/search")
|
||||||
@@ -114,13 +143,7 @@ async def search_notes():
|
|||||||
.limit(100)
|
.limit(100)
|
||||||
)
|
)
|
||||||
notes = (await db.scalars(stmt)).all()
|
notes = (await db.scalars(stmt)).all()
|
||||||
labels_map = await _labels_for_notes(db, [n.id for n in notes])
|
return jsonify({"notes": await _serialize_notes(db, notes)})
|
||||||
out = []
|
|
||||||
for n in notes:
|
|
||||||
data = n.serialize()
|
|
||||||
data["labels"] = labels_map.get(n.id, [])
|
|
||||||
out.append(data)
|
|
||||||
return jsonify({"notes": out})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("")
|
@bp.post("")
|
||||||
@@ -175,6 +198,8 @@ async def update_note(note_id: str):
|
|||||||
note.body = data["body"]
|
note.body = data["body"]
|
||||||
if "color" in data:
|
if "color" in data:
|
||||||
note.color = normalize_color(data["color"])
|
note.color = normalize_color(data["color"])
|
||||||
|
if "kind" in data and data["kind"] in ("text", "list"):
|
||||||
|
note.kind = data["kind"]
|
||||||
if "pinned" in data:
|
if "pinned" in data:
|
||||||
note.pinned = bool(data["pinned"])
|
note.pinned = bool(data["pinned"])
|
||||||
if "archived" in data:
|
if "archived" in data:
|
||||||
@@ -219,6 +244,89 @@ async def set_note_labels(note_id: str):
|
|||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
|
||||||
|
try:
|
||||||
|
iid = uuid.UUID(item_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return await db.scalar(select(NoteItem).where(NoteItem.id == iid, NoteItem.note_id == note.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<note_id>/items")
|
||||||
|
@login_required
|
||||||
|
async def add_item(note_id: str):
|
||||||
|
data = await request.get_json(silent=True) or {}
|
||||||
|
text = data["text"].strip() if isinstance(data.get("text"), str) else ""
|
||||||
|
async with session_scope() as db:
|
||||||
|
note = await _get_owned(db, note_id)
|
||||||
|
if note is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
max_pos = await db.scalar(
|
||||||
|
select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id)
|
||||||
|
)
|
||||||
|
db.add(NoteItem(note_id=note.id, text=text, position=int(max_pos) + 1))
|
||||||
|
await db.commit()
|
||||||
|
return jsonify(await _serialize_note(db, note)), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bp.patch("/<note_id>/items/<item_id>")
|
||||||
|
@login_required
|
||||||
|
async def update_item(note_id: str, item_id: str):
|
||||||
|
data = await request.get_json(silent=True) or {}
|
||||||
|
async with session_scope() as db:
|
||||||
|
note = await _get_owned(db, note_id)
|
||||||
|
if note is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
item = await _get_item(db, note, item_id)
|
||||||
|
if item is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
if "text" in data and isinstance(data["text"], str):
|
||||||
|
item.text = data["text"]
|
||||||
|
if "checked" in data:
|
||||||
|
item.checked = bool(data["checked"])
|
||||||
|
await db.commit()
|
||||||
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/<note_id>/items/<item_id>")
|
||||||
|
@login_required
|
||||||
|
async def delete_item(note_id: str, item_id: str):
|
||||||
|
async with session_scope() as db:
|
||||||
|
note = await _get_owned(db, note_id)
|
||||||
|
if note is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
item = await _get_item(db, note, item_id)
|
||||||
|
if item is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
await db.delete(item)
|
||||||
|
await db.commit()
|
||||||
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<note_id>/items/reorder")
|
||||||
|
@login_required
|
||||||
|
async def reorder_items(note_id: str):
|
||||||
|
data = await request.get_json(silent=True) or {}
|
||||||
|
order = data.get("item_ids")
|
||||||
|
if not isinstance(order, list):
|
||||||
|
return jsonify({"error": "item_ids must be a list"}), 400
|
||||||
|
async with session_scope() as db:
|
||||||
|
note = await _get_owned(db, note_id)
|
||||||
|
if note is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
existing = {
|
||||||
|
str(i.id): i for i in (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
|
||||||
|
}
|
||||||
|
pos = 0
|
||||||
|
for iid in order:
|
||||||
|
item = existing.get(str(iid))
|
||||||
|
if item is not None:
|
||||||
|
item.position = pos
|
||||||
|
pos += 1
|
||||||
|
await db.commit()
|
||||||
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<note_id>/trash")
|
@bp.post("/<note_id>/trash")
|
||||||
@login_required
|
@login_required
|
||||||
async def trash_note(note_id: str):
|
async def trash_note(note_id: str):
|
||||||
|
|||||||
@@ -56,3 +56,9 @@ async def test_search_requires_auth(app):
|
|||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.get("/api/notes/search?q=hello")
|
resp = await client.get("/api/notes/search?q=hello")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_item_requires_auth(app):
|
||||||
|
client = app.test_client()
|
||||||
|
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/items", json={"text": "x"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|||||||
Reference in New Issue
Block a user