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
|
<input
|
||||||
v-model="title"
|
v-model="title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Title"
|
placeholder="Title (optional)"
|
||||||
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<div v-if="liveNote.kind === 'text'" class="relative">
|
<div v-if="liveNote.kind === 'text'" class="relative">
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface Attachment {
|
|||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
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;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
kind: NoteKind;
|
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
|
"""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
|
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
|
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)
|
source = aliased(Note)
|
||||||
target = aliased(Note)
|
target = aliased(Note)
|
||||||
edge_stmt = (
|
edge_stmt = (
|
||||||
select(source.id, target.id)
|
select(source.id, target.id)
|
||||||
.select_from(NoteLink)
|
.select_from(NoteLink)
|
||||||
.join(source, source.id == NoteLink.source_id)
|
.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(
|
.where(
|
||||||
source.owner_id == g.user_id,
|
source.owner_id == g.user_id,
|
||||||
source.deleted_at.is_(None),
|
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)))
|
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
||||||
).all()
|
).all()
|
||||||
nodes = [
|
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
|
for n in note_rows
|
||||||
]
|
]
|
||||||
return jsonify({"nodes": nodes, "edges": edges})
|
return jsonify({"nodes": nodes, "edges": edges})
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ class Note(Base):
|
|||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
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="")
|
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||||
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
||||||
# 'text' (freeform body) or 'list' (a checklist of note_items).
|
# 'text' (freeform body) or 'list' (a checklist of note_items).
|
||||||
@@ -59,6 +64,7 @@ class Note(Base):
|
|||||||
return {
|
return {
|
||||||
"id": str(self.id),
|
"id": str(self.id),
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
|
"display_title": self.display_title,
|
||||||
"body": self.body,
|
"body": self.body,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
"kind": self.kind,
|
"kind": self.kind,
|
||||||
|
|||||||
+40
-20
@@ -38,6 +38,22 @@ bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
|||||||
|
|
||||||
VALID_FILTERS = {"active", "archived", "trash"}
|
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:
|
def is_empty_note(title: str | None, body: str | None) -> bool:
|
||||||
return not (title or "").strip() and not (body or "").strip()
|
return not (title or "").strip() and not (body or "").strip()
|
||||||
@@ -253,15 +269,16 @@ async def list_reminders():
|
|||||||
@bp.get("/titles")
|
@bp.get("/titles")
|
||||||
@login_required
|
@login_required
|
||||||
async def list_titles():
|
async def list_titles():
|
||||||
# Owner's non-trashed titled notes — the index the frontend uses to resolve
|
# Owner's non-trashed notes, keyed by their display NAME (explicit title or
|
||||||
# [[wiki-links]] client-side.
|
# 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:
|
async with session_scope() as db:
|
||||||
rows = (
|
rows = (
|
||||||
await db.scalars(
|
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
||||||
select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None), Note.title.is_not(None))
|
|
||||||
)
|
|
||||||
).all()
|
).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")
|
@bp.get("/<note_id>/backlinks")
|
||||||
@@ -277,9 +294,9 @@ async def note_backlinks(note_id: str):
|
|||||||
)
|
)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
if not note.title:
|
if not note.display_title:
|
||||||
return jsonify({"backlinks": []})
|
return jsonify({"backlinks": []})
|
||||||
norm = note.title.strip().lower()
|
norm = note.display_title.strip().lower()
|
||||||
sources = (
|
sources = (
|
||||||
await db.scalars(
|
await db.scalars(
|
||||||
select(Note)
|
select(Note)
|
||||||
@@ -297,7 +314,7 @@ async def note_backlinks(note_id: str):
|
|||||||
for n in sources:
|
for n in sources:
|
||||||
if n.id not in seen:
|
if n.id not in seen:
|
||||||
seen.add(n.id)
|
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})
|
return jsonify({"backlinks": out})
|
||||||
|
|
||||||
|
|
||||||
@@ -343,9 +360,11 @@ async def create_note():
|
|||||||
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
|
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
clean_title = title.strip() or None
|
||||||
note = Note(
|
note = Note(
|
||||||
owner_id=g.user_id,
|
owner_id=g.user_id,
|
||||||
title=title.strip() or None,
|
title=clean_title,
|
||||||
|
display_title=derive_display_title(clean_title, body),
|
||||||
body=body,
|
body=body,
|
||||||
color=normalize_color(data.get("color")),
|
color=normalize_color(data.get("color")),
|
||||||
position=int(max_pos) + 1,
|
position=int(max_pos) + 1,
|
||||||
@@ -382,7 +401,7 @@ async def update_note(note_id: str):
|
|||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
old_title = note.title
|
old_display = note.display_title
|
||||||
if "title" in data:
|
if "title" in data:
|
||||||
title = data["title"] if isinstance(data["title"], str) else ""
|
title = data["title"] if isinstance(data["title"], str) else ""
|
||||||
note.title = title.strip() or None
|
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"))
|
note.remind_at = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "invalid remind_at"}), 400
|
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:
|
if "body" in data:
|
||||||
await _rewrite_links(db, note)
|
await _rewrite_links(db, note)
|
||||||
# A rename repoints inbound [[Old Title]] references so backlinks survive
|
# The display NAME changing — via an explicit title OR the first body line —
|
||||||
# (skip pure case/whitespace changes, which still resolve).
|
# repoints inbound [[Old Name]] references so backlinks survive (skip pure
|
||||||
if (
|
# case/whitespace changes, which still resolve).
|
||||||
"title" in data
|
new_display = note.display_title
|
||||||
and old_title
|
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
|
||||||
and note.title
|
await _rename_inbound_links(db, note, old_display, new_display)
|
||||||
and old_title.strip().lower() != note.title.strip().lower()
|
|
||||||
):
|
|
||||||
await _rename_inbound_links(db, note, old_title, note.title)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|||||||
+33
-1
@@ -2,7 +2,13 @@ import pytest
|
|||||||
|
|
||||||
from thoughtsync.app import create_app
|
from thoughtsync.app import create_app
|
||||||
from thoughtsync.models.note import NOTE_COLORS, Note
|
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
|
@pytest.fixture
|
||||||
@@ -97,6 +103,32 @@ def test_rewrite_link_title_noop():
|
|||||||
assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here"
|
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):
|
async def test_titles_requires_auth(app):
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.get("/api/notes/titles")
|
resp = await client.get("/api/notes/titles")
|
||||||
|
|||||||
Reference in New Issue
Block a user