Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s

Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
This commit is contained in:
2026-08-22 19:33:57 -04:00
parent 6d778f26a7
commit 95aa10c2c3
29 changed files with 420 additions and 435 deletions
+82
View File
@@ -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)")
@@ -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
},
)
@@ -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)
@@ -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) },
)
}
}
@@ -22,7 +22,6 @@ sealed interface EditorAction {
data object DismissError : EditorAction
data class SaveText(
val title: String,
val body: String,
) : EditorAction
@@ -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,
@@ -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,
)
}
@@ -10,7 +10,6 @@
<!-- Compose sheet -->
<string name="compose_open">New note</string>
<string name="compose_title_hint">Title</string>
<string name="compose_body_hint">Take a note…</string>
<string name="compose_discard">Discard</string>
<string name="compose_save">Save</string>
@@ -38,7 +37,6 @@
<!-- Editor -->
<string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string>
<string name="editor_title_hint">Title</string>
<string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Note</string>
<string name="editor_add_item">Add item</string>
+25 -43
View File
@@ -568,9 +568,8 @@ mod tests {
dir.to_string_lossy().into_owned()
}
fn draft(title: &str, body: &str) -> NoteDraft {
fn draft(body: &str) -> NoteDraft {
NoteDraft {
title: title.to_string(),
body: body.to_string(),
color: "default".to_string(),
items: None,
@@ -587,62 +586,50 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("Groceries", "milk"))
.create_note(draft("Groceries\nmilk"))
.expect("create should succeed");
assert_eq!(created.title.as_deref(), Some("Groceries"));
assert_eq!(created.body, "milk");
assert_eq!(created.body, "Groceries\nmilk");
let fetched = app
.get_note(created.id.clone())
.expect("get should succeed");
assert_eq!(fetched.id, created.id);
// The NAME is the first line — there is no title field to have set (M13 step 3).
assert_eq!(fetched.display_title, "Groceries");
std::fs::remove_dir_all(&dir).ok();
}
/// A body-only note still has to be nameable — that is what `display_title` is
/// for, and the Android board relies on it exactly as the desktop does.
/// Every note has to be nameable — that is what `display_title` is for, and the
/// Android board relies on it exactly as the desktop does.
#[test]
fn body_only_notes_still_have_a_display_title() {
fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app
.create_note(draft("", "just a thought"))
.create_note(draft("just a thought"))
.expect("create should succeed");
assert_eq!(created.title, None);
assert_eq!(created.display_title, "just a thought");
std::fs::remove_dir_all(&dir).ok();
}
/// Clearing a field and setting one are different edits, and the difference has
/// to survive the trip through the patch object.
/// The hole that made removing the title unsafe until checklists stopped being
/// their own kind of thing: a note with no body text still needs a name.
#[test]
fn edits_can_both_set_and_clear_a_title() {
fn a_note_with_only_items_is_named_by_its_first_item() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("First", "body")).expect("create");
let renamed = app
.update_note(
note.id.clone(),
vec![NoteEdit::Title {
value: "Second".to_string(),
}],
)
.expect("rename");
assert_eq!(renamed.title.as_deref(), Some("Second"));
let cleared = app
.update_note(note.id.clone(), vec![NoteEdit::ClearTitle])
.expect("clear");
assert_eq!(
cleared.title, None,
"ClearTitle must null the column, not set it to an empty string — the \
distinction is why NoteEdit is a list rather than a struct of options"
);
let created = app
.create_note(NoteDraft {
body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
assert_eq!(created.display_title, "milk");
std::fs::remove_dir_all(&dir).ok();
}
@@ -673,8 +660,7 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(NoteDraft {
title: "Packing".to_string(),
body: String::new(),
body: "Packing".to_string(),
color: "default".to_string(),
items: Some(vec!["socks".to_string()]),
})
@@ -728,7 +714,7 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Trip", "book the ferry #travel"))
.create_note(draft("Trip\nbook the ferry #travel"))
.expect("create");
assert_eq!(
note.labels.len(),
@@ -767,7 +753,7 @@ mod tests {
fn deleting_forever_removes_the_note() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Ephemeral", "body")).expect("create");
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
app.delete_note_forever(note.id.clone())
.expect("delete forever");
@@ -784,7 +770,7 @@ mod tests {
fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Call back", "")).expect("create");
let note = app.create_note(draft("Call back")).expect("create");
assert_eq!(note.remind_at, None);
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
@@ -810,9 +796,7 @@ mod tests {
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Water the plants", ""))
.expect("create");
let note = app.create_note(draft("Water the plants")).expect("create");
let armed = app
.update_note(
@@ -849,9 +833,7 @@ mod tests {
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
// invisibly on a note with no reminder.
let once = app
.create_note(draft("Post the letter", ""))
.expect("create");
let once = app.create_note(draft("Post the letter")).expect("create");
app.update_note(
once.id.clone(),
vec![NoteEdit::RemindAt {
+19 -37
View File
@@ -29,9 +29,8 @@ use thoughtsync_core::sync::state as core_state;
#[derive(Debug, Clone, uniffi::Record)]
pub struct Note {
pub id: String,
pub title: Option<String>,
/// Title if set, else the first body line — always present, so a body-only note
/// is still nameable. Derived by the core, never stored.
/// The note's NAME: its first non-blank body line, else its first checklist item.
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub color: String,
@@ -129,7 +128,6 @@ impl From<core_models::Note> for Note {
// Exhaustive on purpose — see the module header.
let core_models::Note {
id,
title,
display_title,
body,
color,
@@ -149,7 +147,6 @@ impl From<core_models::Note> for Note {
} = value;
Note {
id,
title,
display_title,
body,
color,
@@ -341,7 +338,6 @@ impl From<NoteFacets> for core_models::Facets {
/// A new note.
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub title: String,
pub body: String,
/// "default" unless the user picked a colour.
pub color: String,
@@ -352,18 +348,8 @@ pub struct NoteDraft {
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft {
title,
body,
color,
items,
} = value;
core_models::NoteCreateInput {
title,
body,
color,
items,
}
let NoteDraft { body, color, items } = value;
core_models::NoteCreateInput { body, color, items }
}
}
@@ -371,14 +357,12 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
///
/// A LIST of these rather than a struct of optional fields, because the core's patch
/// semantics distinguish three states — leave alone, set to a value, and clear to
/// null — and Kotlin has no way to express the third with a nullable field. `title:
/// null` in a data class is indistinguishable from `title` unset, so the editor
/// could never clear a title. Explicit `Clear*` variants say it out loud, and Kotlin
/// gets a sealed class it can `when` over exhaustively.
/// null — and Kotlin has no way to express the third with a nullable field.
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Title { value: String },
ClearTitle,
Body { value: String },
Color { value: String },
Pinned { value: bool },
@@ -399,8 +383,6 @@ impl NoteEdit {
fn entry(self) -> (&'static str, serde_json::Value) {
use serde_json::Value;
match self {
NoteEdit::Title { value } => ("title", Value::String(value)),
NoteEdit::ClearTitle => ("title", Value::Null),
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
@@ -415,8 +397,8 @@ impl NoteEdit {
/// Fold a list of edits into the single patch object the store applies.
///
/// Later edits win on a repeated key, which is what a caller batching "set title,
/// then clear title" would expect.
/// Later edits win on a repeated key, which is what a caller batching "set a
/// reminder, then clear it" would expect.
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
let mut map = serde_json::Map::new();
for edit in edits {
@@ -697,14 +679,14 @@ mod tests {
#[test]
fn a_set_and_a_clear_are_different_patch_entries() {
let set = patch_from(vec![NoteEdit::Title {
value: "x".to_string(),
let set = patch_from(vec![NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
}]);
assert_eq!(set["title"], serde_json::json!("x"));
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
let cleared = patch_from(vec![NoteEdit::ClearTitle]);
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
assert!(
cleared["title"].is_null(),
cleared["remind_at"].is_null(),
"a clear must reach the store as JSON null — an absent key means \
'leave alone', which is a different instruction"
);
@@ -720,11 +702,11 @@ mod tests {
#[test]
fn later_edits_win_on_a_repeated_field() {
let patch = patch_from(vec![
NoteEdit::Title {
value: "first".to_string(),
NoteEdit::RemindAt {
value: "2026-01-01T00:00:00Z".to_string(),
},
NoteEdit::ClearTitle,
NoteEdit::ClearRemindAt,
]);
assert!(patch["title"].is_null());
assert!(patch["remind_at"].is_null());
}
}
+3 -6
View File
@@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct Note {
pub id: String,
pub title: Option<String>,
/// 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<String>,
pub body: String,
pub created_at: Option<String>,
}
@@ -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")]
+8 -8
View File
@@ -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");
+14
View File
@@ -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(())
}
+66 -74
View File
@@ -24,30 +24,26 @@ fn new_id() -> String {
Uuid::new_v4().to_string()
}
/// title if non-empty, else the first non-blank body line — always a string.
fn display_title(title: Option<&str>, body: &str) -> String {
if let Some(t) = title {
let t = t.trim();
if !t.is_empty() {
return t.to_string();
}
/// The note's NAME: its first non-blank body line, else its first checklist item.
///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire.
///
/// Pure, and given the items rather than fetching them: every caller has already
/// loaded them, so a query here would be a second trip for something already in hand.
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
return line.to_string();
}
body.lines()
.map(str::trim)
.find(|l| !l.is_empty())
items
.iter()
.map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("")
.to_string()
}
fn normalize_title(raw: &str) -> Option<String> {
let t = raw.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
fn escape_like(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
@@ -139,32 +135,29 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row(
"SELECT id, title, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
let title: Option<String> = r.get(1)?;
let body: String = r.get(2)?;
let dt = display_title(title.as_deref(), &body);
let body: String = r.get(1)?;
Ok(Note {
id: r.get(0)?,
title,
display_title: dt,
display_title: String::new(), // filled below — it may need a query
body,
color: r.get(3)?,
position: r.get(4)?,
pinned: r.get(5)?,
archived: r.get(6)?,
trashed: r.get(7)?,
deleted_at: r.get(12)?,
remind_at: r.get(8)?,
recurrence: r.get(9)?,
color: r.get(2)?,
position: r.get(3)?,
pinned: r.get(4)?,
archived: r.get(5)?,
trashed: r.get(6)?,
deleted_at: r.get(11)?,
remind_at: r.get(7)?,
recurrence: r.get(8)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
previews: Vec::new(),
created_at: r.get(10)?,
updated_at: r.get(11)?,
created_at: r.get(9)?,
updated_at: r.get(10)?,
})
},
)?;
@@ -172,6 +165,8 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
note.items = load_items(conn, id)?;
note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?;
// After the items, because a body-only-empty note is named by its first one.
note.display_title = display_title(&note.body, &note.items);
Ok(note)
}
@@ -267,7 +262,7 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
if let Some(f) = &q.facets {
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
let pat = format!("%{}%", escape_like(text));
binds.push(pat.clone());
binds.push(pat);
@@ -321,23 +316,31 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
}
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| {
let title: Option<String> = r.get(1)?;
let body: String = r.get(2)?;
Ok(TitleEntry {
id: r.get(0)?,
title: display_title(title.as_deref(), &body),
// Names come from `load_note` rather than from a bare row, because a note whose
// body is empty is named by its first checklist item — which a row here doesn't
// have. The command palette reads this; correctness beats one query per note at
// personal scale.
let ids: Vec<String> = {
let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
};
ids.iter()
.map(|id| {
let note = load_note(conn, id)?;
Ok(TitleEntry {
id: note.id,
title: note.display_title,
})
})
})?;
rows.collect()
.collect()
}
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
let pat = format!("%{}%", escape_like(q));
let ids: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT id FROM notes WHERE trashed = 0 AND (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC",
"SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC",
)?;
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
@@ -350,16 +353,15 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
let id = new_id();
let ts = now();
let title = normalize_title(&input.title);
let position: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
[],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO notes (id, title, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, 1)",
params![id, title, input.body, input.color, position, ts],
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, input.body, input.color, position, ts],
)?;
if let Some(items) = &input.items {
for (i, text) in items.iter().enumerate() {
@@ -374,13 +376,11 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
}
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let (title, body): (Option<String>, String) =
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
let body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
conn.execute(
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![new_id(), id, title, body, now()],
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, body, now()],
)?;
Ok(())
}
@@ -391,20 +391,13 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
.as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit title/body once if either is being changed (version history).
if obj.contains_key("title") || obj.contains_key("body") {
// Snapshot the pre-edit body before changing it (version history).
if obj.contains_key("body") {
snapshot_revision(conn, id)?;
}
for (k, v) in obj {
match k.as_str() {
"title" => {
let norm = v.as_str().and_then(normalize_title);
conn.execute(
"UPDATE notes SET title = ?1 WHERE id = ?2",
params![norm, id],
)?;
}
"body" => {
let body = v.as_str().unwrap_or("");
conn.execute(
@@ -653,28 +646,27 @@ pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<(
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
let mut stmt = conn
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
.prepare("SELECT id, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
let rows = stmt.query_map([id], |r| {
Ok(NoteRevision {
id: r.get(0)?,
title: r.get(1)?,
body: r.get(2)?,
created_at: r.get(3)?,
body: r.get(1)?,
created_at: r.get(2)?,
})
})?;
rows.collect()
}
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
let (title, body): (Option<String>, String) = conn.query_row(
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
let body: String = conn.query_row(
"SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
params![rev_id, id],
|r| Ok((r.get(0)?, r.get(1)?)),
|r| r.get(0),
)?;
snapshot_revision(conn, id)?;
conn.execute(
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
params![title, body, id],
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, &body)?;
touch(conn, id)?;
+2 -5
View File
@@ -240,12 +240,11 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
conn.execute(
"INSERT INTO notes (id, title, body, color, position, pinned, archived,
"INSERT INTO notes (id, body, color, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, 0)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
body = excluded.body,
color = excluded.color,
position = excluded.position,
@@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
dirty = 0",
params![
note.id,
note.title,
note.body,
note.color,
note.position,
@@ -496,7 +494,6 @@ mod tests {
fn note(id: &str, revision: i64) -> wire::Note {
wire::Note {
id: id.to_string(),
title: Some("Title".into()),
body: "Body".into(),
color: "default".into(),
position: 0,
+13 -20
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[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<Change>, 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<Change>, 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<String>,
body: String,
color: String,
position: i64,
@@ -248,23 +243,22 @@ struct NoteRow {
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
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<Change> {
// 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],
)
-2
View File
@@ -24,8 +24,6 @@ pub struct ChangesPage {
pub struct Note {
pub id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
+1 -2
View File
@@ -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<Note, "title" | "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>;
export interface ChecklistItemChanges {
+2 -5
View File
@@ -221,14 +221,11 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
@click="emit('open', note)"
@keydown.enter="emit('open', note)"
>
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{{ note.title }}
</h3>
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="note.body" />
</div>
<p
v-if="!note.title && !note.body && !note.items.length && !note.attachments.length"
v-if="!note.body && !note.items.length && !note.attachments.length"
class="text-sm italic text-neutral-400"
>
Empty note
@@ -236,7 +233,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</div>
<NoteChecklist
v-if="note.items.length"
:class="note.body || note.title ? 'mt-2' : ''"
:class="note.body ? 'mt-2' : ''"
:note-id="note.id"
:items="note.items"
@click="emit('open', note)"
+11 -33
View File
@@ -27,7 +27,6 @@ const notes = useNotesStore();
const config = useConfigStore();
const noteId = ref<string | null>(props.note?.id ?? null);
const title = ref(props.note?.title ?? "");
const body = ref(props.note?.body ?? props.initialBody);
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
@@ -42,14 +41,13 @@ const fileInput = ref<HTMLInputElement | null>(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<Note>(() => ({
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<void> {
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<void> {
}
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<void> {
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 {
</div>
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
<input
v-model="title"
type="text"
placeholder="Title (optional)"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
@keydown.enter="onTitleEnter"
/>
<textarea
ref="bodyInput"
v-model="body"
+5 -8
View File
@@ -52,19 +52,17 @@ export interface LinkPreview {
site_name: string | null;
}
// A past version of a note's title+body (version history).
// A past version of a note's body (version history).
export interface NoteRevision {
id: string;
title: string | null;
body: string;
created_at: string | null;
}
export interface Note {
id: string;
title: string | null;
// The note's display NAME: explicit title, else its first body line (server-derived).
// Every note has one, so a body-only note still has something to be called.
// The note's NAME: its first body line, else its first checklist item
// (server-derived). Every note has one, so every note has something to be called.
display_title: string;
body: string;
color: NoteColor;
@@ -134,10 +132,9 @@ export const useNotesStore = defineStore("notes", () => {
}
async function create(input: {
title: string;
body: string;
color: NoteColor;
items?: string[];
items?: string[];
}): Promise<Note> {
const note = await repo.notes.create(input);
reconcile(note);
@@ -147,7 +144,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate(
id: string,
changes: Partial<
Pick<Note, "title" | "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>,
): Promise<void> {
reconcile(await repo.notes.update(id, changes));
+5 -6
View File
@@ -38,11 +38,11 @@ class Note(Base):
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
# The note's display NAME: explicit title if set, else the first non-empty body
# line (see notes.derive_display_title). Persisted so every note — even a body-only
# one — has something to be called in search results and in an export filename,
# without forcing the user to type a title.
# The note's NAME: its first non-empty body line, else its first checklist item
# (see notes.derive_display_title). There is no title field to prefer — a note is
# a body plus optional items, and this is simply the first thing written in it.
# Persisted so search results and export filenames have something to say, and so
# the full-text vector can weight it above the rest of the body.
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
@@ -73,7 +73,6 @@ class Note(Base):
def serialize(self) -> dict:
return {
"id": str(self.id),
"title": self.title,
"display_title": self.display_title,
"body": self.body,
"color": self.color,
+3 -4
View File
@@ -11,9 +11,9 @@ from . import Base
class NoteRevision(Base):
"""A point-in-time snapshot of a note's title+body, written on each edit that
changes either — so an accidental overwrite can be viewed and restored. Only
title+body are versioned in v1 (not items/attachments/labels)."""
"""A point-in-time snapshot of a note's body, written on each edit that changes
it — so an accidental overwrite can be viewed and restored. Only the body is
versioned (not items/attachments/labels)."""
__tablename__ = "note_revisions"
__table_args__ = (Index("ix_note_revisions_note_created", "note_id", "created_at"),)
@@ -22,6 +22,5 @@ class NoteRevision(Base):
note_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+32 -28
View File
@@ -139,8 +139,9 @@ async def list_notes():
return json_error("invalid created_before", 400)
stmt = stmt.where(Note.created_at < before_dt)
if query_text:
# Full-text match over title+body (generated tsvector, migration 0005),
# ranked — so the facet bar's text box searches, not just filters.
# Full-text match over the note's name + body (generated tsvector,
# migrations 0005/0026), ranked — so the facet bar's text box searches,
# not just filters.
tsquery = func.websearch_to_tsquery("english", query_text)
search_col = literal_column("notes.search_vector")
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
@@ -273,7 +274,6 @@ async def export_notes():
payload["notes"].append(
{
"id": str(n.id),
"title": n.title,
"display_title": n.display_title,
"body": n.body,
"color": n.color,
@@ -406,17 +406,32 @@ async def reorder_notes():
return jsonify({"ok": True})
async def _name_for(db, note: Note, item_texts: list[str] | None = None) -> str:
"""The note's display name, consulting its checklist only when the body is silent.
`item_texts` short-circuits the query for callers that already hold the items
(create, import). Everyone else pays one narrow SELECT, and only when the body
produced nothing — which is the uncommon case.
"""
name = derive_display_title(note.body)
if name:
return name
if item_texts is not None:
return derive_display_title("", item_texts[0] if item_texts else None)
first = await db.scalar(
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
)
return derive_display_title("", first)
@bp.post("")
@login_required
async def create_note():
data = await request.get_json(silent=True) or {}
title = data.get("title") if isinstance(data.get("title"), str) else ""
body = data.get("body") if isinstance(data.get("body"), str) else ""
# Items are accepted on ANY note now — a checklist is something a note HAS.
# Items are accepted on ANY note — a checklist is something a note HAS.
item_texts = parse_list_items(data.get("items"))
# "Empty" therefore means all three are empty, not just the two that used to
# matter for whichever kind this was.
if is_empty_note(title, body) and not item_texts:
if is_empty_note(body, item_texts):
return json_error("note is empty", 400)
async with session_scope() as db:
# New notes go to the top of the manual order.
@@ -425,11 +440,9 @@ async def create_note():
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
)
)
clean_title = title.strip() or None
note = Note(
owner_id=g.user_id,
title=clean_title,
display_title=derive_display_title(clean_title, body),
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
body=body,
color=normalize_color(data.get("color")),
position=int(max_pos) + 1,
@@ -467,11 +480,7 @@ async def update_note(note_id: str):
note = await _get_owned(db, note_id)
if note is None:
return not_found()
old_title = note.title
old_body = note.body
if "title" in data:
title = data["title"] if isinstance(data["title"], str) else ""
note.title = title.strip() or None
if "body" in data and isinstance(data["body"], str):
note.body = data["body"]
if "color" in data:
@@ -492,15 +501,12 @@ async def update_note(note_id: str):
note.remind_at = remind_dt
if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"])
# Recompute the display name (explicit title, else first body line) whenever
# the title or body may have changed.
if "title" in data or "body" in data:
note.display_title = derive_display_title(note.title, note.body)
if "body" in data:
note.display_title = await _name_for(db, note)
await _reconcile_tags(db, note)
# Version history: snapshot the PRE-edit title+body whenever either changed.
if note.title != old_title or note.body != old_body:
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
# Version history: snapshot the PRE-edit body whenever it changed.
if note.body != old_body:
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
@@ -509,7 +515,6 @@ async def update_note(note_id: str):
def _serialize_revision(rev: NoteRevision) -> dict:
return {
"id": str(rev.id),
"title": rev.title,
"body": rev.body,
"created_at": iso(rev.created_at),
}
@@ -546,14 +551,13 @@ async def restore_revision(note_id: str, rev_id: str):
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
if rev is None:
return not_found()
if note.title == rev.title and note.body == rev.body:
if note.body == rev.body:
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
# the revision — with the same title/body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
note.title = rev.title
# the revision — with the same body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = rev.body
note.display_title = derive_display_title(note.title, note.body)
note.display_title = await _name_for(db, note)
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
+18 -10
View File
@@ -19,22 +19,30 @@ VALID_FILTERS = {"active", "archived", "trash"}
DISPLAY_TITLE_CAP = 200
def derive_display_title(title: str | None, body: str | None) -> str:
"""The note's display NAME: the explicit title if set, else the first non-empty
line of the body (trimmed, length-capped). Persisted as notes.display_title so a
body-only note is still nameable/searchable/linkable — the user never has to type
a title. Deterministic (literal first line, no AI)."""
if title and title.strip():
return title.strip()[:DISPLAY_TITLE_CAP]
def derive_display_title(body: str | None, first_item: str | None = None) -> str:
"""The note's display NAME: the first non-empty line of the body, else the first
checklist item's text (both trimmed and length-capped).
There is no explicit title to prefer any more (M13 step 3) — a note is a body plus
optional items, and its name is simply the first thing written in it. Persisted as
notes.display_title so search results and export filenames have something to say.
The item fallback is what step 2 bought: a note that is only a checklist would
otherwise have no name at all, which is exactly the hole that made removing the
title unsafe before checklists stopped being their own kind of thing.
Deterministic — a literal first line, never generated.
"""
for line in (body or "").splitlines():
stripped = line.strip()
if stripped:
return stripped[:DISPLAY_TITLE_CAP]
return ""
return (first_item or "").strip()[:DISPLAY_TITLE_CAP]
def is_empty_note(title: str | None, body: str | None) -> bool:
return not (title or "").strip() and not (body or "").strip()
def is_empty_note(body: str | None, items: list | None = None) -> bool:
"""Nothing worth keeping: no body text and no checklist items."""
return not (body or "").strip() and not items
def parse_list_items(raw: object) -> list[str]:
+17 -9
View File
@@ -36,8 +36,6 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
"""One note as a human-readable Markdown file with a small frontmatter block.
The authoritative machine format is notes.json; this is for reading/portability."""
fm = ["---"]
if note.title:
fm.append(f"title: {note.title}")
fm.append(f"display_name: {note.display_title}")
if labels:
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
@@ -276,19 +274,29 @@ async def _create_imported_note(
db, owner_id, spec: dict, zf: zipfile.ZipFile, position: int, budget: _ImportBudget
) -> bool:
"""Insert one imported note plus its items/labels/attachments, reusing the same
display-title derivation + tag/link reconciliation as create_note. Returns False
(nothing written) when the spec is empty."""
title = (spec.get("title") or "").strip() or None
name derivation + tag reconciliation as create_note. Returns False (nothing
written) when the spec is empty."""
body = spec.get("body") or ""
# An imported title becomes the note's FIRST BODY LINE.
#
# ThoughtSync has no title field any more (M13 step 3), but the things people
# import from do — Keep notes carry one, and so does any export taken before this.
# Dropping it would silently lose text someone wrote; folding it into the body puts
# it exactly where a name now lives, so the note comes in named the way it was.
# Skipped when the body already opens with that line, so re-importing an export
# this code produced doesn't stack duplicates.
title = (spec.get("title") or "").strip()
if title and body.lstrip().split("\n", 1)[0].strip() != title:
body = f"{title}\n{body}" if body.strip() else title
items = spec.get("items") or []
has_items = any((it.get("text") or "").strip() for it in items)
if is_empty_note(title, body) and not has_items:
item_texts = [t for t in ((it.get("text") or "").strip() for it in items) if t]
if is_empty_note(body, item_texts):
return False
note = Note(
owner_id=owner_id,
title=title,
display_title=derive_display_title(title, body),
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
body=body,
color=normalize_color(spec.get("color")),
pinned=bool(spec.get("pinned")),
-1
View File
@@ -89,7 +89,6 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
note.title = None
note.body = ""
note.display_title = ""
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
+28 -13
View File
@@ -55,13 +55,12 @@ MAX_PUSH = 1000 # per-batch change cap
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise
# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch
# that hard-blocks older clients, so additive changes must leave it alone.
# v2 (M13): `kind` left the wire. Dropping a field a v1 client sends and expects back
# is breaking, so the FLOOR moves too — a v1 client would keep pushing a `kind` the
# server no longer stores, and would read back notes without one.
# v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends
# and expects back is breaking, so the FLOOR moves too — a v1 client would keep pushing
# both and would read back notes carrying neither.
#
# `title` goes the same way in step 3. It lands in this same protocol generation, so
# it needs no further bump — v2 means "no kind, no title", and nothing has run against
# a half-applied v2.
# One bump for the pair: they landed in the same protocol generation, and nothing ever
# ran against a half-applied v2.
SYNC_PROTOCOL_VERSION = 2
MIN_CLIENT_PROTOCOL_VERSION = 2
@@ -197,8 +196,6 @@ def client_wins(client_edited_at: datetime | None, server_edited_at: datetime |
def _assign_note_fields(note: Note, ch: dict) -> None:
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
whole-note, not a partial patch — the client sends its authoritative version)."""
title = ch.get("title")
note.title = (title or "").strip() or None if isinstance(title, str) else None
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
note.color = normalize_color(ch.get("color"))
note.pinned = bool(ch.get("pinned"))
@@ -214,6 +211,24 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
note.position = ch["position"]
def _first_item_text(ch: dict) -> str:
"""The first non-blank checklist item in a pushed change, or "".
Read straight from the payload rather than the database because the note's name is
computed BEFORE `_apply_note_items` has written anything — and a note whose body is
empty is named by its first item (M13 step 3).
"""
items = ch.get("items")
if not isinstance(items, list):
return ""
for it in items:
if isinstance(it, dict):
text = (it.get("text") or "").strip()
if text:
return text
return ""
async def _apply_note_items(db, note: Note, ch: dict) -> None:
"""Replace the note's checklist items with the client's (items sync inline).
@@ -296,14 +311,14 @@ async def _apply_note(db, ch: dict) -> dict:
elif note.purged_at is not None:
note.purged_at = None # client re-created/edited → clear the tombstone
old_title, old_body = note.title, note.body
old_body = note.body
_assign_note_fields(note, ch)
note.display_title = derive_display_title(note.title, note.body)
note.display_title = derive_display_title(note.body, _first_item_text(ch))
if edited_at is not None:
note.updated_at = edited_at
# Non-destructive LWW: snapshot the overwritten server title+body into history.
if not creating and (note.title != old_title or note.body != old_body):
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
# Non-destructive LWW: snapshot the overwritten server body into history.
if not creating and note.body != old_body:
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.flush() # assign note.id before items/labels/links
await _apply_note_items(db, note, ch)
await _reconcile_tags(db, note)
+28 -21
View File
@@ -51,9 +51,10 @@ def test_all_note_routes_registered(app):
def test_is_empty_note():
assert is_empty_note(None, None)
assert is_empty_note("", " ")
assert not is_empty_note("title", "")
assert not is_empty_note("", "body")
assert is_empty_note(" ", [])
assert not is_empty_note("body")
# A note that is only a checklist is not empty — it just has nothing in its body.
assert not is_empty_note("", ["milk"])
def test_normalize_color():
@@ -69,9 +70,9 @@ def test_palette_has_core_colors():
def test_serialize_shape():
n = Note(title="t", body="b", color="blue", pinned=True, archived=False)
n = Note(body="b", color="blue", pinned=True, archived=False)
s = n.serialize()
assert s["title"] == "t"
assert "title" not in s # there is no title field any more (M13 step 3)
assert s["body"] == "b"
assert s["color"] == "blue"
assert s["pinned"] is True
@@ -122,30 +123,34 @@ async def test_reorder_requires_auth(app):
# notice a route coming back, and the removal is one commit rather than a fossil.
def test_derive_display_title_explicit_wins():
assert derive_display_title("My Title", "some body line") == "My Title"
assert derive_display_title(" Padded ", "body") == "Padded"
def test_derive_display_title_from_first_body_line():
assert derive_display_title(None, "first line\nsecond line") == "first line"
assert derive_display_title("", " spaced first \nnext") == "spaced first"
def test_derive_display_title_is_the_first_body_line():
assert derive_display_title("first line\nsecond line") == "first line"
assert derive_display_title(" spaced first \nnext") == "spaced first"
# leading blank/whitespace lines are skipped to the first line with content
assert derive_display_title(None, "\n \nreal line\nmore") == "real line"
# a whitespace-only title falls through to the body
assert derive_display_title(" ", "body wins") == "body wins"
assert derive_display_title("\n \nreal line\nmore") == "real line"
def test_derive_display_title_falls_back_to_the_first_item():
# What step 2 bought: a note that is only a checklist still has a name. Without
# this it would have none at all, which is why the title could not go first.
assert derive_display_title("", "milk") == "milk"
assert derive_display_title(" \n ", " eggs ") == "eggs"
# The body still wins when it has anything to say.
assert derive_display_title("shopping", "milk") == "shopping"
def test_derive_display_title_empty():
assert derive_display_title(None, None) == ""
assert derive_display_title("", "") == ""
assert derive_display_title(" ", " \n ") == ""
assert derive_display_title(None) == ""
assert derive_display_title("") == ""
assert derive_display_title(" \n ", None) == ""
assert derive_display_title(" \n ", " ") == ""
def test_derive_display_title_caps_length():
long = "x" * 300
assert derive_display_title(None, long) == "x" * 200
assert derive_display_title(long, "body") == "x" * 200
assert derive_display_title(long) == "x" * 200
# the item fallback is capped on the same rule
assert derive_display_title("", long) == "x" * 200
def test_parse_tags():
@@ -371,6 +376,8 @@ def test_native_spec_roundtrip_fields():
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
}
spec = _native_spec(n)
# The spec still CARRIES a title — an export taken before M13 has one, and
# _create_imported_note folds it into the body rather than dropping it.
assert spec["title"] == "T"
assert spec["body"] == "b"
assert spec["color"] == "blue"