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:
@@ -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")
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
+33
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user