M2 checklists: note kind + items backend + editor/card UI
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 27s

- 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:
2026-07-19 22:13:25 -04:00
co-authored by Claude Opus 4.8
parent ffc008bf4d
commit 31be66ac60
11 changed files with 353 additions and 18 deletions
+1 -1
View File
@@ -3,4 +3,4 @@
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
+3
View File
@@ -40,6 +40,8 @@ class Note(Base):
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
body: Mapped[str] = mapped_column(Text(), nullable=False, server_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())
archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
# Soft delete: non-null => in Trash. Restore sets it back to null.
@@ -55,6 +57,7 @@ class Note(Base):
"title": self.title,
"body": self.body,
"color": self.color,
"kind": self.kind,
"pinned": self.pinned,
"archived": self.archived,
"trashed": self.deleted_at is not None,
+25
View File
@@ -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
View File
@@ -11,6 +11,7 @@ from .auth import login_required
from .db import session_scope
from .models.label import Label, NoteLabel
from .models.note import NOTE_COLORS, Note
from .models.note_item import NoteItem
bp = Blueprint("notes", __name__, url_prefix="/api/notes")
@@ -50,13 +51,47 @@ async def _labels_for_notes(db, note_ids: list) -> dict:
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:
data = note.serialize()
labels = await _labels_for_notes(db, [note.id])
data["labels"] = labels.get(note.id, [])
items = await _items_for_notes(db, [note.id])
data["items"] = items.get(note.id, [])
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:
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
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.order_by(Note.pinned.desc(), Note.updated_at.desc())
notes = (await db.scalars(stmt)).all()
labels_map = await _labels_for_notes(db, [n.id for n in notes])
out = []
for n in notes:
data = n.serialize()
data["labels"] = labels_map.get(n.id, [])
out.append(data)
return jsonify({"notes": out})
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.get("/search")
@@ -114,13 +143,7 @@ async def search_notes():
.limit(100)
)
notes = (await db.scalars(stmt)).all()
labels_map = await _labels_for_notes(db, [n.id for n in notes])
out = []
for n in notes:
data = n.serialize()
data["labels"] = labels_map.get(n.id, [])
out.append(data)
return jsonify({"notes": out})
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.post("")
@@ -175,6 +198,8 @@ async def update_note(note_id: str):
note.body = data["body"]
if "color" in data:
note.color = normalize_color(data["color"])
if "kind" in data and data["kind"] in ("text", "list"):
note.kind = data["kind"]
if "pinned" in data:
note.pinned = bool(data["pinned"])
if "archived" in data:
@@ -219,6 +244,89 @@ async def set_note_labels(note_id: str):
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")
@login_required
async def trash_note(note_id: str):