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
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:
+25
-43
@@ -568,9 +568,8 @@ mod tests {
|
||||
dir.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn draft(title: &str, body: &str) -> NoteDraft {
|
||||
fn draft(body: &str) -> NoteDraft {
|
||||
NoteDraft {
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
color: "default".to_string(),
|
||||
items: None,
|
||||
@@ -587,62 +586,50 @@ mod tests {
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("Groceries", "milk"))
|
||||
.create_note(draft("Groceries\nmilk"))
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.title.as_deref(), Some("Groceries"));
|
||||
assert_eq!(created.body, "milk");
|
||||
assert_eq!(created.body, "Groceries\nmilk");
|
||||
|
||||
let fetched = app
|
||||
.get_note(created.id.clone())
|
||||
.expect("get should succeed");
|
||||
assert_eq!(fetched.id, created.id);
|
||||
// The NAME is the first line — there is no title field to have set (M13 step 3).
|
||||
assert_eq!(fetched.display_title, "Groceries");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A body-only note still has to be nameable — that is what `display_title` is
|
||||
/// for, and the Android board relies on it exactly as the desktop does.
|
||||
/// Every note has to be nameable — that is what `display_title` is for, and the
|
||||
/// Android board relies on it exactly as the desktop does.
|
||||
#[test]
|
||||
fn body_only_notes_still_have_a_display_title() {
|
||||
fn a_note_is_named_by_its_first_line() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("", "just a thought"))
|
||||
.create_note(draft("just a thought"))
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.title, None);
|
||||
assert_eq!(created.display_title, "just a thought");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clearing a field and setting one are different edits, and the difference has
|
||||
/// to survive the trip through the patch object.
|
||||
/// The hole that made removing the title unsafe until checklists stopped being
|
||||
/// their own kind of thing: a note with no body text still needs a name.
|
||||
#[test]
|
||||
fn edits_can_both_set_and_clear_a_title() {
|
||||
fn a_note_with_only_items_is_named_by_its_first_item() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("First", "body")).expect("create");
|
||||
|
||||
let renamed = app
|
||||
.update_note(
|
||||
note.id.clone(),
|
||||
vec![NoteEdit::Title {
|
||||
value: "Second".to_string(),
|
||||
}],
|
||||
)
|
||||
.expect("rename");
|
||||
assert_eq!(renamed.title.as_deref(), Some("Second"));
|
||||
|
||||
let cleared = app
|
||||
.update_note(note.id.clone(), vec![NoteEdit::ClearTitle])
|
||||
.expect("clear");
|
||||
assert_eq!(
|
||||
cleared.title, None,
|
||||
"ClearTitle must null the column, not set it to an empty string — the \
|
||||
distinction is why NoteEdit is a list rather than a struct of options"
|
||||
);
|
||||
let created = app
|
||||
.create_note(NoteDraft {
|
||||
body: String::new(),
|
||||
color: "default".to_string(),
|
||||
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
||||
})
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.display_title, "milk");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -673,8 +660,7 @@ mod tests {
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app
|
||||
.create_note(NoteDraft {
|
||||
title: "Packing".to_string(),
|
||||
body: String::new(),
|
||||
body: "Packing".to_string(),
|
||||
color: "default".to_string(),
|
||||
items: Some(vec!["socks".to_string()]),
|
||||
})
|
||||
@@ -728,7 +714,7 @@ mod tests {
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let note = app
|
||||
.create_note(draft("Trip", "book the ferry #travel"))
|
||||
.create_note(draft("Trip\nbook the ferry #travel"))
|
||||
.expect("create");
|
||||
assert_eq!(
|
||||
note.labels.len(),
|
||||
@@ -767,7 +753,7 @@ mod tests {
|
||||
fn deleting_forever_removes_the_note() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Ephemeral", "body")).expect("create");
|
||||
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
||||
|
||||
app.delete_note_forever(note.id.clone())
|
||||
.expect("delete forever");
|
||||
@@ -784,7 +770,7 @@ mod tests {
|
||||
fn reminders_can_be_snoozed_and_completed() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Call back", "")).expect("create");
|
||||
let note = app.create_note(draft("Call back")).expect("create");
|
||||
assert_eq!(note.remind_at, None);
|
||||
|
||||
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
|
||||
@@ -810,9 +796,7 @@ mod tests {
|
||||
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app
|
||||
.create_note(draft("Water the plants", ""))
|
||||
.expect("create");
|
||||
let note = app.create_note(draft("Water the plants")).expect("create");
|
||||
|
||||
let armed = app
|
||||
.update_note(
|
||||
@@ -849,9 +833,7 @@ mod tests {
|
||||
|
||||
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
|
||||
// invisibly on a note with no reminder.
|
||||
let once = app
|
||||
.create_note(draft("Post the letter", ""))
|
||||
.expect("create");
|
||||
let once = app.create_note(draft("Post the letter")).expect("create");
|
||||
app.update_note(
|
||||
once.id.clone(),
|
||||
vec![NoteEdit::RemindAt {
|
||||
|
||||
+19
-37
@@ -29,9 +29,8 @@ use thoughtsync_core::sync::state as core_state;
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
/// Title if set, else the first body line — always present, so a body-only note
|
||||
/// is still nameable. Derived by the core, never stored.
|
||||
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||
/// Always present. Derived by the core, never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
@@ -129,7 +128,6 @@ impl From<core_models::Note> for Note {
|
||||
// Exhaustive on purpose — see the module header.
|
||||
let core_models::Note {
|
||||
id,
|
||||
title,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
@@ -149,7 +147,6 @@ impl From<core_models::Note> for Note {
|
||||
} = value;
|
||||
Note {
|
||||
id,
|
||||
title,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
@@ -341,7 +338,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
/// A new note.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteDraft {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// "default" unless the user picked a colour.
|
||||
pub color: String,
|
||||
@@ -352,18 +348,8 @@ pub struct NoteDraft {
|
||||
|
||||
impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||
fn from(value: NoteDraft) -> Self {
|
||||
let NoteDraft {
|
||||
title,
|
||||
body,
|
||||
color,
|
||||
items,
|
||||
} = value;
|
||||
core_models::NoteCreateInput {
|
||||
title,
|
||||
body,
|
||||
color,
|
||||
items,
|
||||
}
|
||||
let NoteDraft { body, color, items } = value;
|
||||
core_models::NoteCreateInput { body, color, items }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,14 +357,12 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||
///
|
||||
/// A LIST of these rather than a struct of optional fields, because the core's patch
|
||||
/// semantics distinguish three states — leave alone, set to a value, and clear to
|
||||
/// null — and Kotlin has no way to express the third with a nullable field. `title:
|
||||
/// null` in a data class is indistinguishable from `title` unset, so the editor
|
||||
/// could never clear a title. Explicit `Clear*` variants say it out loud, and Kotlin
|
||||
/// gets a sealed class it can `when` over exhaustively.
|
||||
/// null — and Kotlin has no way to express the third with a nullable field.
|
||||
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
|
||||
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
|
||||
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum NoteEdit {
|
||||
Title { value: String },
|
||||
ClearTitle,
|
||||
Body { value: String },
|
||||
Color { value: String },
|
||||
Pinned { value: bool },
|
||||
@@ -399,8 +383,6 @@ impl NoteEdit {
|
||||
fn entry(self) -> (&'static str, serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
match self {
|
||||
NoteEdit::Title { value } => ("title", Value::String(value)),
|
||||
NoteEdit::ClearTitle => ("title", Value::Null),
|
||||
NoteEdit::Body { value } => ("body", Value::String(value)),
|
||||
NoteEdit::Color { value } => ("color", Value::String(value)),
|
||||
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||
@@ -415,8 +397,8 @@ impl NoteEdit {
|
||||
|
||||
/// Fold a list of edits into the single patch object the store applies.
|
||||
///
|
||||
/// Later edits win on a repeated key, which is what a caller batching "set title,
|
||||
/// then clear title" would expect.
|
||||
/// Later edits win on a repeated key, which is what a caller batching "set a
|
||||
/// reminder, then clear it" would expect.
|
||||
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
|
||||
let mut map = serde_json::Map::new();
|
||||
for edit in edits {
|
||||
@@ -697,14 +679,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_set_and_a_clear_are_different_patch_entries() {
|
||||
let set = patch_from(vec![NoteEdit::Title {
|
||||
value: "x".to_string(),
|
||||
let set = patch_from(vec![NoteEdit::RemindAt {
|
||||
value: "2026-01-01T00:00:00Z".to_string(),
|
||||
}]);
|
||||
assert_eq!(set["title"], serde_json::json!("x"));
|
||||
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
|
||||
|
||||
let cleared = patch_from(vec![NoteEdit::ClearTitle]);
|
||||
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
|
||||
assert!(
|
||||
cleared["title"].is_null(),
|
||||
cleared["remind_at"].is_null(),
|
||||
"a clear must reach the store as JSON null — an absent key means \
|
||||
'leave alone', which is a different instruction"
|
||||
);
|
||||
@@ -720,11 +702,11 @@ mod tests {
|
||||
#[test]
|
||||
fn later_edits_win_on_a_repeated_field() {
|
||||
let patch = patch_from(vec![
|
||||
NoteEdit::Title {
|
||||
value: "first".to_string(),
|
||||
NoteEdit::RemindAt {
|
||||
value: "2026-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
NoteEdit::ClearTitle,
|
||||
NoteEdit::ClearRemindAt,
|
||||
]);
|
||||
assert!(patch["title"].is_null());
|
||||
assert!(patch["remind_at"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user