m4.5: titles optional — every note gets an auto display name
Capture starts in the body, so forcing a title feels odd and body-only notes had no name — which made them unlinkable. Fix both: persist a display_title = explicit title if set, else the note's first non-empty body line (deterministic, no AI). The title field stays optional. - migration 0012: notes.display_title (NOT NULL, best-effort backfill; the app recomputes precisely on next save) - derive_display_title() helper, set on create + update - drive the /titles index, backlinks, graph edges + node labels, and [[wiki-link]] resolution off display_title so body-only notes are nameable, findable (command palette / [[ autocomplete), and linkable - rename-repoint generalized: inbound [[Old Name]] links now survive a name change via the first body line too, not just an explicit title - unit tests for the derivation (explicit wins, first non-empty line, blank/empty, length cap) - frontend: display_title on the Note type; title field placeholder now reads "Title (optional)" First item of M4.5 (frictionless input & recall); unblocks the linking work. Card rendering unchanged (no first-line duplication). 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:
@@ -19,14 +19,14 @@ async def get_graph():
|
||||
"""Wiki-link graph. Nodes are ALL of the owner's non-trashed notes (the frontend
|
||||
toggles whether to show unlinked ones); each carries its first label's color for
|
||||
clustering. Edges are resolved [[links]] (note_links.target_norm matched to a
|
||||
note's normalized title)."""
|
||||
note's normalized display_title — its explicit title or first body line)."""
|
||||
source = aliased(Note)
|
||||
target = aliased(Note)
|
||||
edge_stmt = (
|
||||
select(source.id, target.id)
|
||||
.select_from(NoteLink)
|
||||
.join(source, source.id == NoteLink.source_id)
|
||||
.join(target, func.lower(func.trim(target.title)) == NoteLink.target_norm)
|
||||
.join(target, func.lower(func.trim(target.display_title)) == NoteLink.target_norm)
|
||||
.where(
|
||||
source.owner_id == g.user_id,
|
||||
source.deleted_at.is_(None),
|
||||
@@ -63,7 +63,7 @@ async def get_graph():
|
||||
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
||||
).all()
|
||||
nodes = [
|
||||
{"id": str(n.id), "title": n.title or "Untitled", "color": first_color.get(n.id, "default")}
|
||||
{"id": str(n.id), "title": n.display_title or "Untitled", "color": first_color.get(n.id, "default")}
|
||||
for n in note_rows
|
||||
]
|
||||
return jsonify({"nodes": nodes, "edges": edges})
|
||||
|
||||
@@ -38,6 +38,11 @@ class Note(Base):
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
# The note's display NAME: explicit title if set, else the first non-empty body
|
||||
# line (see notes.derive_display_title). Persisted + normalized-matched so every
|
||||
# note — even a body-only one — is nameable, searchable, graphable, and
|
||||
# [[wiki-link]]-able without forcing the user to type a title.
|
||||
display_title: 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")
|
||||
# 'text' (freeform body) or 'list' (a checklist of note_items).
|
||||
@@ -59,6 +64,7 @@ class Note(Base):
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"title": self.title,
|
||||
"display_title": self.display_title,
|
||||
"body": self.body,
|
||||
"color": self.color,
|
||||
"kind": self.kind,
|
||||
|
||||
+40
-20
@@ -38,6 +38,22 @@ bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
||||
|
||||
VALID_FILTERS = {"active", "archived", "trash"}
|
||||
|
||||
DISPLAY_TITLE_CAP = 200
|
||||
|
||||
|
||||
def derive_display_title(title: str | None, body: str | None) -> str:
|
||||
"""The note's display NAME: the explicit title if set, else the first non-empty
|
||||
line of the body (trimmed, length-capped). Persisted as notes.display_title so a
|
||||
body-only note is still nameable/searchable/linkable — the user never has to type
|
||||
a title. Deterministic (literal first line, no AI)."""
|
||||
if title and title.strip():
|
||||
return title.strip()[:DISPLAY_TITLE_CAP]
|
||||
for line in (body or "").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped[:DISPLAY_TITLE_CAP]
|
||||
return ""
|
||||
|
||||
|
||||
def is_empty_note(title: str | None, body: str | None) -> bool:
|
||||
return not (title or "").strip() and not (body or "").strip()
|
||||
@@ -253,15 +269,16 @@ async def list_reminders():
|
||||
@bp.get("/titles")
|
||||
@login_required
|
||||
async def list_titles():
|
||||
# Owner's non-trashed titled notes — the index the frontend uses to resolve
|
||||
# [[wiki-links]] client-side.
|
||||
# Owner's non-trashed notes, keyed by their display NAME (explicit title or
|
||||
# first body line) — the index the frontend uses to resolve + autocomplete
|
||||
# [[wiki-links]]. Every note has a name now, so body-only notes are linkable too.
|
||||
async with session_scope() as db:
|
||||
rows = (
|
||||
await db.scalars(
|
||||
select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None), Note.title.is_not(None))
|
||||
)
|
||||
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
||||
).all()
|
||||
return jsonify({"titles": [{"id": str(n.id), "title": n.title} for n in rows]})
|
||||
return jsonify(
|
||||
{"titles": [{"id": str(n.id), "title": n.display_title} for n in rows if n.display_title]}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<note_id>/backlinks")
|
||||
@@ -277,9 +294,9 @@ async def note_backlinks(note_id: str):
|
||||
)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
if not note.title:
|
||||
if not note.display_title:
|
||||
return jsonify({"backlinks": []})
|
||||
norm = note.title.strip().lower()
|
||||
norm = note.display_title.strip().lower()
|
||||
sources = (
|
||||
await db.scalars(
|
||||
select(Note)
|
||||
@@ -297,7 +314,7 @@ async def note_backlinks(note_id: str):
|
||||
for n in sources:
|
||||
if n.id not in seen:
|
||||
seen.add(n.id)
|
||||
out.append({"id": str(n.id), "title": n.title})
|
||||
out.append({"id": str(n.id), "title": n.display_title})
|
||||
return jsonify({"backlinks": out})
|
||||
|
||||
|
||||
@@ -343,9 +360,11 @@ async def create_note():
|
||||
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
clean_title = title.strip() or None
|
||||
note = Note(
|
||||
owner_id=g.user_id,
|
||||
title=title.strip() or None,
|
||||
title=clean_title,
|
||||
display_title=derive_display_title(clean_title, body),
|
||||
body=body,
|
||||
color=normalize_color(data.get("color")),
|
||||
position=int(max_pos) + 1,
|
||||
@@ -382,7 +401,7 @@ async def update_note(note_id: str):
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
old_title = note.title
|
||||
old_display = note.display_title
|
||||
if "title" in data:
|
||||
title = data["title"] if isinstance(data["title"], str) else ""
|
||||
note.title = title.strip() or None
|
||||
@@ -405,17 +424,18 @@ async def update_note(note_id: str):
|
||||
note.remind_at = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid remind_at"}), 400
|
||||
# Recompute the display name (explicit title, else first body line) whenever
|
||||
# the title or body may have changed.
|
||||
if "title" in data or "body" in data:
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
if "body" in data:
|
||||
await _rewrite_links(db, note)
|
||||
# A rename repoints inbound [[Old Title]] references so backlinks survive
|
||||
# (skip pure case/whitespace changes, which still resolve).
|
||||
if (
|
||||
"title" in data
|
||||
and old_title
|
||||
and note.title
|
||||
and old_title.strip().lower() != note.title.strip().lower()
|
||||
):
|
||||
await _rename_inbound_links(db, note, old_title, note.title)
|
||||
# The display NAME changing — via an explicit title OR the first body line —
|
||||
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
|
||||
# case/whitespace changes, which still resolve).
|
||||
new_display = note.display_title
|
||||
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
|
||||
await _rename_inbound_links(db, note, old_display, new_display)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
|
||||
Reference in New Issue
Block a user