Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s

Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
This commit is contained in:
2026-08-22 19:33:57 -04:00
parent 6d778f26a7
commit 95aa10c2c3
29 changed files with 420 additions and 435 deletions
+32 -28
View File
@@ -139,8 +139,9 @@ async def list_notes():
return json_error("invalid created_before", 400)
stmt = stmt.where(Note.created_at < before_dt)
if query_text:
# Full-text match over title+body (generated tsvector, migration 0005),
# ranked — so the facet bar's text box searches, not just filters.
# Full-text match over the note's name + body (generated tsvector,
# migrations 0005/0026), ranked — so the facet bar's text box searches,
# not just filters.
tsquery = func.websearch_to_tsquery("english", query_text)
search_col = literal_column("notes.search_vector")
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
@@ -273,7 +274,6 @@ async def export_notes():
payload["notes"].append(
{
"id": str(n.id),
"title": n.title,
"display_title": n.display_title,
"body": n.body,
"color": n.color,
@@ -406,17 +406,32 @@ async def reorder_notes():
return jsonify({"ok": True})
async def _name_for(db, note: Note, item_texts: list[str] | None = None) -> str:
"""The note's display name, consulting its checklist only when the body is silent.
`item_texts` short-circuits the query for callers that already hold the items
(create, import). Everyone else pays one narrow SELECT, and only when the body
produced nothing — which is the uncommon case.
"""
name = derive_display_title(note.body)
if name:
return name
if item_texts is not None:
return derive_display_title("", item_texts[0] if item_texts else None)
first = await db.scalar(
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
)
return derive_display_title("", first)
@bp.post("")
@login_required
async def create_note():
data = await request.get_json(silent=True) or {}
title = data.get("title") if isinstance(data.get("title"), str) else ""
body = data.get("body") if isinstance(data.get("body"), str) else ""
# Items are accepted on ANY note now — a checklist is something a note HAS.
# Items are accepted on ANY note — a checklist is something a note HAS.
item_texts = parse_list_items(data.get("items"))
# "Empty" therefore means all three are empty, not just the two that used to
# matter for whichever kind this was.
if is_empty_note(title, body) and not item_texts:
if is_empty_note(body, item_texts):
return json_error("note is empty", 400)
async with session_scope() as db:
# New notes go to the top of the manual order.
@@ -425,11 +440,9 @@ 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=clean_title,
display_title=derive_display_title(clean_title, body),
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
body=body,
color=normalize_color(data.get("color")),
position=int(max_pos) + 1,
@@ -467,11 +480,7 @@ async def update_note(note_id: str):
note = await _get_owned(db, note_id)
if note is None:
return not_found()
old_title = note.title
old_body = note.body
if "title" in data:
title = data["title"] if isinstance(data["title"], str) else ""
note.title = title.strip() or None
if "body" in data and isinstance(data["body"], str):
note.body = data["body"]
if "color" in data:
@@ -492,15 +501,12 @@ async def update_note(note_id: str):
note.remind_at = remind_dt
if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"])
# 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:
note.display_title = await _name_for(db, note)
await _reconcile_tags(db, note)
# Version history: snapshot the PRE-edit title+body whenever either changed.
if note.title != old_title or note.body != old_body:
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
# Version history: snapshot the PRE-edit body whenever it changed.
if note.body != old_body:
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
@@ -509,7 +515,6 @@ async def update_note(note_id: str):
def _serialize_revision(rev: NoteRevision) -> dict:
return {
"id": str(rev.id),
"title": rev.title,
"body": rev.body,
"created_at": iso(rev.created_at),
}
@@ -546,14 +551,13 @@ async def restore_revision(note_id: str, rev_id: str):
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
if rev is None:
return not_found()
if note.title == rev.title and note.body == rev.body:
if note.body == rev.body:
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
# the revision — with the same title/body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
note.title = rev.title
# the revision — with the same body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = rev.body
note.display_title = derive_display_title(note.title, note.body)
note.display_title = await _name_for(db, note)
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)