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
+2 -5
View File
@@ -240,12 +240,11 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
conn.execute(
"INSERT INTO notes (id, title, body, color, position, pinned, archived,
"INSERT INTO notes (id, body, color, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, 0)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
body = excluded.body,
color = excluded.color,
position = excluded.position,
@@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
dirty = 0",
params![
note.id,
note.title,
note.body,
note.color,
note.position,
@@ -496,7 +494,6 @@ mod tests {
fn note(id: &str, revision: i64) -> wire::Note {
wire::Note {
id: id.to_string(),
title: Some("Title".into()),
body: "Body".into(),
color: "default".into(),
position: 0,