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
+66 -74
View File
@@ -24,30 +24,26 @@ fn new_id() -> String {
Uuid::new_v4().to_string()
}
/// title if non-empty, else the first non-blank body line — always a string.
fn display_title(title: Option<&str>, body: &str) -> String {
if let Some(t) = title {
let t = t.trim();
if !t.is_empty() {
return t.to_string();
}
/// The note's NAME: its first non-blank body line, else its first checklist item.
///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire.
///
/// Pure, and given the items rather than fetching them: every caller has already
/// loaded them, so a query here would be a second trip for something already in hand.
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
return line.to_string();
}
body.lines()
.map(str::trim)
.find(|l| !l.is_empty())
items
.iter()
.map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("")
.to_string()
}
fn normalize_title(raw: &str) -> Option<String> {
let t = raw.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
fn escape_like(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
@@ -139,32 +135,29 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row(
"SELECT id, title, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
let title: Option<String> = r.get(1)?;
let body: String = r.get(2)?;
let dt = display_title(title.as_deref(), &body);
let body: String = r.get(1)?;
Ok(Note {
id: r.get(0)?,
title,
display_title: dt,
display_title: String::new(), // filled below — it may need a query
body,
color: r.get(3)?,
position: r.get(4)?,
pinned: r.get(5)?,
archived: r.get(6)?,
trashed: r.get(7)?,
deleted_at: r.get(12)?,
remind_at: r.get(8)?,
recurrence: r.get(9)?,
color: r.get(2)?,
position: r.get(3)?,
pinned: r.get(4)?,
archived: r.get(5)?,
trashed: r.get(6)?,
deleted_at: r.get(11)?,
remind_at: r.get(7)?,
recurrence: r.get(8)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
previews: Vec::new(),
created_at: r.get(10)?,
updated_at: r.get(11)?,
created_at: r.get(9)?,
updated_at: r.get(10)?,
})
},
)?;
@@ -172,6 +165,8 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
note.items = load_items(conn, id)?;
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
// After the items, because a body-only-empty note is named by its first one.
note.display_title = display_title(&note.body, &note.items);
Ok(note)
}
@@ -267,7 +262,7 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
if let Some(f) = &q.facets {
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
let pat = format!("%{}%", escape_like(text));
binds.push(pat.clone());
binds.push(pat);
@@ -321,23 +316,31 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
}
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| {
let title: Option<String> = r.get(1)?;
let body: String = r.get(2)?;
Ok(TitleEntry {
id: r.get(0)?,
title: display_title(title.as_deref(), &body),
// Names come from `load_note` rather than from a bare row, because a note whose
// body is empty is named by its first checklist item — which a row here doesn't
// have. The command palette reads this; correctness beats one query per note at
// personal scale.
let ids: Vec<String> = {
let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
ids.iter()
.map(|id| {
let note = load_note(conn, id)?;
Ok(TitleEntry {
id: note.id,
title: note.display_title,
})
})
})?;
rows.collect()
.collect()
}
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
let pat = format!("%{}%", escape_like(q));
let ids: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT id FROM notes WHERE trashed = 0 AND (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC",
"SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC",
)?;
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
@@ -350,16 +353,15 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
let id = new_id();
let ts = now();
let title = normalize_title(&input.title);
let position: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
[],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO notes (id, title, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, 1)",
params![id, title, input.body, input.color, position, ts],
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, input.body, input.color, position, ts],
)?;
if let Some(items) = &input.items {
for (i, text) in items.iter().enumerate() {
@@ -374,13 +376,11 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
}
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let (title, body): (Option<String>, String) =
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
let body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
conn.execute(
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![new_id(), id, title, body, now()],
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, body, now()],
)?;
Ok(())
}
@@ -391,20 +391,13 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
.as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit title/body once if either is being changed (version history).
if obj.contains_key("title") || obj.contains_key("body") {
// Snapshot the pre-edit body before changing it (version history).
if obj.contains_key("body") {
snapshot_revision(conn, id)?;
}
for (k, v) in obj {
match k.as_str() {
"title" => {
let norm = v.as_str().and_then(normalize_title);
conn.execute(
"UPDATE notes SET title = ?1 WHERE id = ?2",
params![norm, id],
)?;
}
"body" => {
let body = v.as_str().unwrap_or("");
conn.execute(
@@ -653,28 +646,27 @@ pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<(
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
let mut stmt = conn
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
.prepare("SELECT id, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map([id], |r| {
Ok(NoteRevision {
id: r.get(0)?,
title: r.get(1)?,
body: r.get(2)?,
created_at: r.get(3)?,
body: r.get(1)?,
created_at: r.get(2)?,
})
})?;
rows.collect()
}
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
let (title, body): (Option<String>, String) = conn.query_row(
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
let body: String = conn.query_row(
"SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
params![rev_id, id],
|r| Ok((r.get(0)?, r.get(1)?)),
|r| r.get(0),
)?;
snapshot_revision(conn, id)?;
conn.execute(
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
params![title, body, id],
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, &body)?;
touch(conn, id)?;