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
@@ -0,0 +1,36 @@
"""notes.display_title
Revision ID: 0012
Revises: 0011
Create Date: 2026-07-22
"""
from alembic import op
import sqlalchemy as sa
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
def upgrade() -> None:
# The note's display NAME: explicit title if set, else the first body line.
op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default=""))
# Best-effort backfill (kept intentionally simple + unambiguous — CI has no
# Postgres lane, so this only runs on a real deploy). The app recomputes the
# precise value via derive_display_title on the next save (which additionally
# skips leading blank lines). Capped at 200 chars.
op.execute(
r"""
UPDATE notes SET display_title = LEFT(btrim(
CASE
WHEN title IS NOT NULL AND btrim(title) <> '' THEN title
ELSE split_part(body, E'\n', 1)
END
), 200)
"""
)
def downgrade() -> None:
op.drop_column("notes", "display_title")
+1 -1
View File
@@ -279,7 +279,7 @@ async function act(fn: () => Promise<void>) {
<input
v-model="title"
type="text"
placeholder="Title"
placeholder="Title (optional)"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
/>
<div v-if="liveNote.kind === 'text'" class="relative">
+3
View File
@@ -29,6 +29,9 @@ export interface Attachment {
export interface Note {
id: string;
title: string | null;
// The note's display NAME: explicit title, else its first body line (server-derived).
// Every note has one, so body-only notes are still nameable + [[link]]-able.
display_title: string;
body: string;
color: NoteColor;
kind: NoteKind;
+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))
+33 -1
View File
@@ -2,7 +2,13 @@ import pytest
from thoughtsync.app import create_app
from thoughtsync.models.note import NOTE_COLORS, Note
from thoughtsync.notes import is_empty_note, normalize_color, parse_link_titles, rewrite_link_title
from thoughtsync.notes import (
derive_display_title,
is_empty_note,
normalize_color,
parse_link_titles,
rewrite_link_title,
)
@pytest.fixture
@@ -97,6 +103,32 @@ def test_rewrite_link_title_noop():
assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here"
def test_derive_display_title_explicit_wins():
assert derive_display_title("My Title", "some body line") == "My Title"
assert derive_display_title(" Padded ", "body") == "Padded"
def test_derive_display_title_from_first_body_line():
assert derive_display_title(None, "first line\nsecond line") == "first line"
assert derive_display_title("", " spaced first \nnext") == "spaced first"
# leading blank/whitespace lines are skipped to the first line with content
assert derive_display_title(None, "\n \nreal line\nmore") == "real line"
# a whitespace-only title falls through to the body
assert derive_display_title(" ", "body wins") == "body wins"
def test_derive_display_title_empty():
assert derive_display_title(None, None) == ""
assert derive_display_title("", "") == ""
assert derive_display_title(" ", " \n ") == ""
def test_derive_display_title_caps_length():
long = "x" * 300
assert derive_display_title(None, long) == "x" * 200
assert derive_display_title(long, "body") == "x" * 200
async def test_titles_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/titles")