diff --git a/alembic/versions/0026_drop_note_title.py b/alembic/versions/0026_drop_note_title.py new file mode 100644 index 0000000..e00bdc5 --- /dev/null +++ b/alembic/versions/0026_drop_note_title.py @@ -0,0 +1,82 @@ +"""drop notes.title and note_revisions.title — a note's name is its first line + +Revision ID: 0026 +Revises: 0025 +Create Date: 2026-08-22 + +M13 step 3. A note is a body plus optional checkable items; its NAME is the first +non-empty line of that body, falling back to its first checklist item. There is no +separate field to type into, and `display_title` (already persisted, already what +search results and export filenames read) carries the name. + +## The search vector has to be rebuilt, not just left alone + +`notes.search_vector` is a STORED GENERATED column whose expression names `title` +(migration 0005, weight A) — Postgres will refuse to drop a column another generated +column depends on, and even if it didn't, the weighting would be wrong. So it is +dropped and recreated over `display_title` instead, which keeps the original +intent: the note's NAME ranks above the rest of its body. + +Rebuilding a stored generated column re-computes every row, and the GIN index is +rebuilt with it. On a personal instance that is milliseconds; it is worth knowing +before running this against something large. + +## What happens to existing titles + +Nothing preserves them, deliberately: `display_title` was already derived from the +title when one was set, so every note keeps the NAME it had. What is lost is the +distinction between "this note has an explicit title" and "this note's first line is +its name" — which is the distinction being removed. + +Imports are the exception and are handled in code, not here: a Keep note's title, or +one in an export taken before this, is folded in as the note's first body line rather +than dropped (see `_create_imported_note`). +""" +from alembic import op +import sqlalchemy as sa + +revision = "0026" +down_revision = "0025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Order matters: the generated column depends on `title`, so it goes first. + op.execute("DROP INDEX IF EXISTS ix_notes_search") + op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector") + + op.drop_column("notes", "title") + op.drop_column("note_revisions", "title") + + op.execute( + """ + ALTER TABLE notes ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('english', coalesce(display_title, '')), 'A') || + setweight(to_tsvector('english', coalesce(body, '')), 'B') + ) STORED + """ + ) + op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_notes_search") + op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector") + + # Comes back empty. The text is not gone — it is the first line of every body — + # but which notes once had an explicit title is not recorded anywhere. + op.add_column("notes", sa.Column("title", sa.Text(), nullable=True)) + op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True)) + + op.execute( + """ + ALTER TABLE notes ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce(body, '')), 'B') + ) STORED + """ + ) + op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)") diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index 4ee1ef2..d5d35a1 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -226,8 +226,8 @@ private fun App( ComposeSheet( saving = board.state.saving, onDismiss = { composing = false }, - onSave = { title, content -> - board.create(title, content) + onSave = { content -> + board.create(content) composing = false }, ) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index 146dd27..a76bf5f 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -194,19 +194,15 @@ class BoardViewModel( * Blank input is ignored rather than rejected: an empty save is a slip, not a * mistake worth interrupting someone over. */ - fun create( - title: String, - content: String, - ) { - val cleanTitle = title.trim() + fun create(content: String) { val cleanContent = content.trim() - if (cleanTitle.isEmpty() && cleanContent.isEmpty()) return + if (cleanContent.isEmpty()) return viewModelScope.launch { state = state.copy(saving = true) state = try { - val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanTitle, cleanContent)) } + val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) } // Prepend rather than reload: the new note belongs at the top // of the board, and a full re-query would cost a round trip to // tell us what we already know. Skipped when the board is not @@ -277,28 +273,10 @@ class BoardViewModel( EditorAction.Close -> state = state.copy(editing = null) EditorAction.DismissError -> dismissError() - // Text is the only edit that batches: title and body are typed - // together and saved together on close, so they cost one write and - // one revision snapshot rather than two of each. + // Saved on close rather than per keystroke, so a session of typing + // costs one write and one revision snapshot. is EditorAction.SaveText -> - mutate { - it.updateNote( - id, - listOf( - // An emptied title CLEARS the column rather than - // storing "". The core derives `display_title` from - // the first body line when the title is null, so the - // difference is whether an untitled note is nameable - // or blank — exactly what `ClearTitle` exists for. - if (action.title.isBlank()) { - NoteEdit.ClearTitle - } else { - NoteEdit.Title(action.title.trim()) - }, - NoteEdit.Body(action.body), - ), - ) - } + mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) } is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color)) @@ -464,12 +442,8 @@ private fun query( labelId: String? = null, ) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null) -private fun draft( - title: String, - content: String, -): NoteDraft = - // Body carries the text; the core derives display_title from its first line when - // no title was given, so a captured thought is nameable without making the user - // name it. A checklist is added afterwards, in the editor — it is something a note - // HAS, not a different thing to capture (M13 step 2). - NoteDraft(title = title, body = content, color = DEFAULT_COLOR, items = null) +private fun draft(content: String): NoteDraft = + // The core names the note from the body's first line, so a captured thought is + // findable without anyone being asked to name it. A checklist is added afterwards, + // in the editor — it is something a note HAS, not a different thing to capture. + NoteDraft(body = content, color = DEFAULT_COLOR, items = null) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt index 545c794..4d86514 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt @@ -57,27 +57,26 @@ import com.fabledsword.thoughtsync.R fun ComposeSheet( saving: Boolean, onDismiss: () -> Unit, - onSave: (String, String) -> Unit, + onSave: (String) -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) // Saveable, not just remembered: a rotation mid-sentence is the same lost // thought as a discarded one, and it was losing it before this. - var title by rememberSaveable { mutableStateOf("") } var content by rememberSaveable { mutableStateOf("") } val contentFocus = remember { FocusRequester() } - val written = title.isNotBlank() || content.isNotBlank() - val leave = { if (written) onSave(title, content) else onDismiss() } + val written = content.isNotBlank() + val leave = { if (written) onSave(content) else onDismiss() } - // Land in the body, not the title. Most captures are a thought, not a titled - // document, and making someone tab past an optional field is the difference - // between "under a second" and not. + // Straight into the one field there is. A capture is a thought, and every field + // someone has to tab past is the difference between "under a second" and not — + // which is why the title field is gone rather than merely skipped (M13 step 3). LaunchedEffect(Unit) { contentFocus.requestFocus() } // Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped // + and then got distracted should find the composer where they left it; the // only reason to act here is that there is something to lose. - FlushOnStop { if (written) onSave(title, content) } + FlushOnStop { if (written) onSave(content) } ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) { Column( @@ -91,13 +90,6 @@ fun ComposeSheet( ) { // No note/list switch any more: there is one thing to capture. A // checklist is added to a note in the editor, once there is a note. - PlainTextField( - value = title, - onValueChange = { title = it }, - hint = R.string.compose_title_hint, - singleLine = true, - ) - PlainTextField( value = content, onValueChange = { content = it }, @@ -109,7 +101,7 @@ fun ComposeSheet( SheetActions( canSave = !saving && written, onDiscard = onDismiss, - onSave = { onSave(title, content) }, + onSave = { onSave(content) }, ) } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt index d7999f7..7bdcd1d 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt @@ -22,7 +22,6 @@ sealed interface EditorAction { data object DismissError : EditorAction data class SaveText( - val title: String, val body: String, ) : EditorAction diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index 4f4af64..ae962f0 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -50,21 +49,9 @@ fun NoteCard( .border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS)) .padding(12.dp), ) { - // A title only renders when one was actually set. `displayTitle` is - // derived from the first body line when it wasn't, so printing both would - // show the same text twice. - note.title?.takeIf { it.isNotBlank() }?.let { title -> - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(4.dp)) - } - - // Both, in order — a note can carry a body AND a checklist (M13 step 2). + // Body then checklist, in order — a note can carry both (M13 step 2), and + // nothing above them: the first line of the body IS the note's name, at the + // same weight as the rest of it (M13 steps 3 and 4). if (note.body.isNotBlank()) { Text( text = note.body, @@ -78,9 +65,9 @@ fun NoteCard( Checklist(items = note.items) } - // A note with no title, no body and no items still has to occupy the - // board legibly — otherwise it reads as a rendering bug. - if (note.title.isNullOrBlank() && note.body.isBlank() && note.items.isEmpty()) { + // A note with no body and no items still has to occupy the board legibly — + // otherwise it reads as a rendering bug. + if (note.body.isBlank() && note.items.isEmpty()) { Text( text = stringResource(R.string.board_empty_note), style = MaterialTheme.typography.bodyMedium, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 87e1bad..cbf5be3 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.Label @@ -62,7 +61,6 @@ fun NoteEditorScreen( // Keyed by note id: the editor is reused across notes, and without the key the // second note opened would show the first one's text. - var title by remember(note.id) { mutableStateOf(note.title.orEmpty()) } var body by remember(note.id) { mutableStateOf(note.body) } var picker by remember(note.id) { mutableStateOf(Picker.NONE) } var confirmingDelete by remember(note.id) { mutableStateOf(false) } @@ -77,8 +75,8 @@ fun NoteEditorScreen( // would bump `updated_at`, mark the note dirty for sync, and snapshot a // revision identical to the one before it. val flush = { - if (!readOnly && (title != note.title.orEmpty() || body != note.body)) { - onAction(EditorAction.SaveText(title, body)) + if (!readOnly && body != note.body) { + onAction(EditorAction.SaveText(body)) } } val leave = { @@ -142,14 +140,9 @@ fun NoteEditorScreen( ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) }) } - EditorField( - value = title, - onValueChange = { title = it }, - hint = R.string.editor_title_hint, - enabled = !readOnly, - bold = true, - ) - + // One field. A note is its body; its NAME is that body's first line, so + // there is nothing separate to type into and nothing to render bolder + // than the line beneath it (M13 steps 3 and 4). EditorField( value = body, onValueChange = { body = it }, @@ -252,11 +245,15 @@ private fun EditorOverlays( } /** - * The title and body fields. + * The note's body field. * * Undecorated, via the shared [PlainTextField]: the screen is already painted in * the note's colour, and a filled field would draw a second surface over the first * and turn a note into a form. + * + * One weight throughout. The first line is the note's name, but it is not a + * different KIND of text from the line after it, and typing it should not feel like + * filling in a header. */ @Composable private fun EditorField( @@ -264,7 +261,6 @@ private fun EditorField( onValueChange: (String) -> Unit, @StringRes hint: Int, enabled: Boolean, - bold: Boolean = false, minLines: Int = 1, ) { PlainTextField( @@ -272,16 +268,8 @@ private fun EditorField( onValueChange = onValueChange, hint = hint, enabled = enabled, - // The title is one line by contract — it is a name, and a name that wraps - // has become a body. The body itself never is. - singleLine = bold, minLines = minLines, - textStyle = - if (bold) { - MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - } else { - MaterialTheme.typography.bodyLarge - }, + textStyle = MaterialTheme.typography.bodyLarge, ) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9978d83..5b232dd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -10,7 +10,6 @@ New note - Title Take a note… Discard Save @@ -38,7 +37,6 @@ Open note Back to notes - Title Add a checklist Note Add item diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index c88a2c8..9adfc1a 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -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 { diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index a202abd..55c5ddc 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -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, - /// 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 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 for Note { } = value; Note { id, - title, display_title, body, color, @@ -341,7 +338,6 @@ impl From 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 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 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) -> 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()); } } diff --git a/core/src/local/models.rs b/core/src/local/models.rs index 33cfc47..3b5b1d0 100644 --- a/core/src/local/models.rs +++ b/core/src/local/models.rs @@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize)] pub struct Note { pub id: String, - pub title: Option, - /// title if set, else the note's first body line — always present, so body-only - /// notes still have something to be called. Derived, never stored. + /// The note's NAME: its first non-blank body line, else its first checklist item. + /// Always present, so every note has something to be called. Derived at read time, + /// never stored. pub display_title: String, pub body: String, pub color: String, @@ -71,7 +71,6 @@ pub struct LinkPreview { #[derive(Serialize)] pub struct NoteRevision { pub id: String, - pub title: Option, pub body: String, pub created_at: Option, } @@ -128,8 +127,6 @@ fn default_color() -> String { #[derive(Deserialize)] pub struct NoteCreateInput { - #[serde(default)] - pub title: String, #[serde(default)] pub body: String, #[serde(default = "default_color")] diff --git a/core/src/local/retention.rs b/core/src/local/retention.rs index d5772d8..0a8ca82 100644 --- a/core/src/local/retention.rs +++ b/core/src/local/retention.rs @@ -93,8 +93,8 @@ mod tests { let when = Utc::now() - age; let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true); conn.execute( - "INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) - VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)", + "INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at) + VALUES (?1, 'B', ?2, ?2, 1, ?2)", rusqlite::params![id, stamped], ) .expect("insert"); @@ -149,8 +149,8 @@ mod tests { fn an_untrashed_note_is_never_swept() { let conn = db(); conn.execute( - "INSERT INTO notes (id, title, body, created_at, updated_at, trashed) - VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)", + "INSERT INTO notes (id, body, created_at, updated_at, trashed) + VALUES ('live', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)", [], ) .expect("insert"); @@ -163,8 +163,8 @@ mod tests { // "Age unknown" must never resolve to "delete it". let conn = db(); conn.execute( - "INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) - VALUES ('weird', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')", + "INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at) + VALUES ('weird', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')", [], ) .expect("insert"); @@ -179,8 +179,8 @@ mod tests { let conn = db(); let stamped = (Utc::now() - Duration::days(40)).to_rfc3339(); conn.execute( - "INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) - VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)", + "INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at) + VALUES ('server', 'B', ?1, ?1, 1, ?1)", rusqlite::params![stamped], ) .expect("insert"); diff --git a/core/src/local/schema.rs b/core/src/local/schema.rs index dbc3af8..8b5decc 100644 --- a/core/src/local/schema.rs +++ b/core/src/local/schema.rs @@ -170,6 +170,16 @@ const SCHEMA_V6: &str = r#" ALTER TABLE notes DROP COLUMN kind; "#; +// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and +// its NAME is the first non-empty line of that body, falling back to its first item — +// derived at read time, never stored (see store::display_title). +// +// note_revisions loses its copy for the same reason: a revision snapshots a body. +const SCHEMA_V7: &str = r#" +ALTER TABLE notes DROP COLUMN title; +ALTER TABLE note_revisions DROP COLUMN title; +"#; + /// Bring the database up to the latest schema. Idempotent. pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch("PRAGMA foreign_keys = ON;")?; @@ -198,5 +208,9 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch(SCHEMA_V6)?; conn.execute_batch("PRAGMA user_version = 6;")?; } + if version < 7 { + conn.execute_batch(SCHEMA_V7)?; + conn.execute_batch("PRAGMA user_version = 7;")?; + } Ok(()) } diff --git a/core/src/local/store.rs b/core/src/local/store.rs index 1c36f17..e267184 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -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 { - 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 rusqlite::Result { 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 = 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.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(¬e.body, ¬e.items); Ok(note) } @@ -267,7 +262,7 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result rusqlite::Result> { } pub fn titles(conn: &Connection) -> rusqlite::Result> { - let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?; - let rows = stmt.query_map([], |r| { - let title: Option = 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 = { + let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?; + let rows = stmt.query_map([], |r| r.get(0))?; + rows.collect::>>()? + }; + 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> { let pat = format!("%{}%", escape_like(q)); let ids: Vec = { 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::>>()? @@ -350,16 +353,15 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result> { pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result { 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) = - 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> { 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 { - let (title, body): (Option, 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)?; diff --git a/core/src/sync/pull.rs b/core/src/sync/pull.rs index b1bff40..977618c 100644 --- a/core/src/sync/pull.rs +++ b/core/src/sync/pull.rs @@ -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, diff --git a/core/src/sync/push.rs b/core/src/sync/push.rs index f39e7de..831629e 100644 --- a/core/src/sync/push.rs +++ b/core/src/sync/push.rs @@ -62,8 +62,6 @@ pub struct Change { pub op: &'static str, pub edited_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -97,7 +95,6 @@ impl Change { id, op: "delete", edited_at, - title: None, body: None, color: None, pinned: None, @@ -197,7 +194,6 @@ fn collect_labels(conn: &Connection, out: &mut Vec, limit: usize) -> rus name: Some(r.get(1)?), color: Some(r.get(2)?), edited_at: r.get(3)?, - title: None, body: None, pinned: None, archived: None, @@ -233,7 +229,6 @@ fn collect_notes(conn: &Connection, out: &mut Vec, limit: usize) -> rusq /// The note's own columns. A named struct rather than a twelve-wide tuple so the /// field-to-column mapping stays readable at the call site. struct NoteRow { - title: Option, body: String, color: String, position: i64, @@ -248,23 +243,22 @@ struct NoteRow { fn note_row(conn: &Connection, id: &str) -> rusqlite::Result { conn.query_row( - "SELECT title, body, color, position, pinned, archived, trashed, + "SELECT body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at FROM notes WHERE id = ?1", params![id], |r| { Ok(NoteRow { - title: r.get(0)?, - body: r.get(1)?, - color: r.get(2)?, - position: r.get(3)?, - pinned: r.get::<_, i64>(4)? != 0, - archived: r.get::<_, i64>(5)? != 0, - trashed: r.get::<_, i64>(6)? != 0, - remind_at: r.get(7)?, - recurrence: r.get(8)?, - created_at: r.get(9)?, - updated_at: r.get(10)?, + body: r.get(0)?, + color: r.get(1)?, + position: r.get(2)?, + pinned: r.get::<_, i64>(3)? != 0, + archived: r.get::<_, i64>(4)? != 0, + trashed: r.get::<_, i64>(5)? != 0, + remind_at: r.get(6)?, + recurrence: r.get(7)?, + created_at: r.get(8)?, + updated_at: r.get(9)?, }) }, ) @@ -303,7 +297,6 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result { // The local `updated_at` IS the client's edit time, which is what the // server's last-write-wins comparison runs against. edited_at: row.updated_at, - title: row.title, body: Some(row.body), color: Some(row.color), pinned: Some(row.pinned), @@ -528,9 +521,9 @@ mod tests { fn seed_note(conn: &Connection, id: &str, dirty: i64) { conn.execute( - "INSERT INTO notes (id, title, body, color, position, pinned, archived, + "INSERT INTO notes (id, body, color, position, pinned, archived, trashed, created_at, updated_at, sync_revision, dirty) - VALUES (?1, 'T', 'B', 'default', 0, 0, 0, 0, + VALUES (?1, 'B', 'default', 0, 0, 0, 0, '2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)", params![id, dirty], ) diff --git a/core/src/sync/wire.rs b/core/src/sync/wire.rs index 8a80dbb..87eb9d9 100644 --- a/core/src/sync/wire.rs +++ b/core/src/sync/wire.rs @@ -24,8 +24,6 @@ pub struct ChangesPage { pub struct Note { pub id: String, #[serde(default)] - pub title: Option, - #[serde(default)] pub body: String, #[serde(default = "default_color")] pub color: String, diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 2efff6e..bdc3ca3 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -32,7 +32,6 @@ export interface NoteListQuery { } export interface NoteCreateInput { - title: string; body: string; color: NoteColor; items?: string[]; @@ -40,7 +39,7 @@ export interface NoteCreateInput { // The mutable subset of a note (PATCH /api/notes/:id). export type NoteChanges = Partial< - Pick + Pick >; export interface ChecklistItemChanges { diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index c8c5916..97be5fb 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -221,14 +221,11 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown)) @click="emit('open', note)" @keydown.enter="emit('open', note)" > -

- {{ note.title }} -

Empty note @@ -236,7 +233,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown)) (props.note?.id ?? null); -const title = ref(props.note?.title ?? ""); const body = ref(props.note?.body ?? props.initialBody); const color = ref(props.note?.color ?? "default"); const labelList = ref(props.note ? [...props.note.labels] : []); @@ -42,14 +41,13 @@ const fileInput = ref(null); const uploadError = ref(""); // Baseline for edit-mode change detection (save only when text actually changed). -const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({ - title: props.note?.title ?? null, +const baseline = ref<{ body: string; color: NoteColor }>({ body: props.note?.body ?? "", color: (props.note?.color ?? "default") as NoteColor, }); const isCreate = computed(() => noteId.value === null); -const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() !== ""); +const hasContent = computed(() => body.value.trim() !== ""); // Rich features need a saved note; in compose they light up once there's content. const richEnabled = computed(() => !isCreate.value || hasContent.value); @@ -57,7 +55,6 @@ const richEnabled = computed(() => !isCreate.value || hasContent.value); // template can read attachments/items/remind_at uniformly. const draftNote = computed(() => ({ id: "", - title: title.value.trim() || null, display_title: "", body: body.value, color: color.value, @@ -96,19 +93,18 @@ watch( () => props.note, (n) => { noteId.value = n?.id ?? null; - title.value = n?.title ?? ""; body.value = n?.body ?? ""; color.value = (n?.color ?? "default") as NoteColor; labelList.value = n ? [...n.labels] : []; - baseline.value = { title: n?.title ?? null, body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor }; + baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor }; }, ); // ---- persistence ---- async function createFromFields(): Promise { - const created = await notes.create({ title: title.value, body: body.value, color: color.value }); + const created = await notes.create({ body: body.value, color: color.value }); noteId.value = created.id; - baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor }; + baseline.value = { body: created.body, color: created.color as NoteColor }; } // Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or @@ -135,12 +131,12 @@ async function flush(): Promise { } const b = baseline.value; const nextBody = body.value; - const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color; + const changed = nextBody !== b.body || color.value !== b.color; if (!changed) return; saving.value = true; try { - await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value }); - baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value }; + await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value }); + baseline.value = { body: nextBody, color: color.value }; } finally { saving.value = false; } @@ -148,12 +144,11 @@ async function flush(): Promise { function resetCompose(): void { noteId.value = null; - title.value = ""; body.value = ""; color.value = "default"; labelList.value = []; checklistOpen.value = false; - baseline.value = { title: null, body: "", color: "default" }; + baseline.value = { body: "", color: "default" }; uploadError.value = ""; } @@ -249,12 +244,6 @@ function onBodyKeydown(e: KeyboardEvent) { } } -function onTitleEnter(e: KeyboardEvent) { - e.preventDefault(); - if (e.shiftKey && isCreate.value) void commitAndContinue(); - else bodyInput.value?.focus(); -} - // ---- reminder ---- const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at)); async function onReminderChange(e: Event) { @@ -413,10 +402,9 @@ async function restoreRevisionAt(revId: string) { const id = noteId.value; if (!id) return; const updated = await notes.restoreRevision(id, revId); - title.value = updated.title ?? ""; body.value = updated.body; color.value = updated.color; - baseline.value = { title: updated.title, body: updated.body, color: updated.color }; + baseline.value = { body: updated.body, color: updated.color }; void loadRevisions(); // the pre-restore state became a new revision } function revLabel(iso: string | null): string { @@ -424,9 +412,7 @@ function revLabel(iso: string | null): string { return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } function revPreview(rev: NoteRevision): string { - const t = (rev.title ?? "").trim(); - const b = rev.body.trim().replace(/\s+/g, " "); - const s = t && b ? `${t} — ${b}` : t || b; + const s = rev.body.trim().replace(/\s+/g, " "); if (!s) return "(empty)"; return s.length > 80 ? `${s.slice(0, 80)}…` : s; } @@ -536,14 +522,6 @@ function revPreview(rev: NoteRevision): string {

{{ unfurlError }}

- -