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
- TitleTake a note…DiscardSave
@@ -38,7 +37,6 @@
Open noteBack to notes
- TitleAdd a checklistNoteAdd 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)"
>
-