m4.5: titles optional — every note gets an auto display name
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 33s

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:
2026-07-22 08:00:01 -04:00
co-authored by Claude Opus 4.8
parent 1b6eb0053b
commit 2b6a353666
7 changed files with 122 additions and 25 deletions
+3 -3
View File
@@ -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})
+6
View File
@@ -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
View File
@@ -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))