From c46a4a770941bf74c1e3bf4183b937216c18a893 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 22 Aug 2026 12:53:53 -0400 Subject: [PATCH] A checklist is something a note has, not something a note is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kind` was never a type. A plain TEXT column with no enum and no CHECK behind it, compared against a hardcoded ("text", "list") tuple in six places; `note_items` was always an ordinary child table keyed by note_id; serialization already emitted `items` whatever the kind; and the Android editor already toggled between the two losslessly, saying so in a comment. The storage has modelled "a body plus optional checkable items" the whole time. This deletes the gates that forbade it. Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter facet, the three import/export branches, the column (alembic 0025); the core's `kind` field, its SQLite column (user_version 6), the sync wire, push and pull; the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`, the compose sheet's Note/List switch, and the branches in the card, the editor and the chrome. The editor's note⇄list toggle becomes "Add a checklist" — on both the web and Android. It is not a conversion any more: nothing moves, nothing is swapped, the body stays exactly where it is and the note gains somewhere to put items. The card renders both, in order. Two things that fell out of the merge rather than being aimed at: - The Keep importer was DISCARDING `textContent` whenever a note also had `listContent`, because the target could only hold one. Both survive now, and the test says so. - Markdown export wrote the body OR the checklist. It writes both. Protocol goes to v2, floor included: dropping a field a v1 client sends and expects back is breaking. `title` leaves in step 3 and lands in the same generation, so it needs no further bump. This is the change that will make the 0.1.227 build on the operator's phone refuse to sync — the in-app updater is independent of the handshake and remains the recovery path. The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical schema and every later block alters it, so removing it there would make a fresh database run V1 without the column and then v6's DROP COLUMN against a column that never existed — "no such column: kind" on every new install. --- alembic/versions/0025_drop_note_kind.py | 35 +++++++ .../fabledsword/thoughtsync/MainActivity.kt | 4 +- .../thoughtsync/ui/BoardViewModel.kt | 37 ++------ .../thoughtsync/ui/ComposeSheet.kt | 31 ++----- .../thoughtsync/ui/EditorAction.kt | 10 +- .../thoughtsync/ui/EditorChrome.kt | 19 ++-- .../fabledsword/thoughtsync/ui/NoteCard.kt | 9 +- .../thoughtsync/ui/NoteEditorScreen.kt | 20 ++-- .../fabledsword/thoughtsync/ui/NoteKind.kt | 16 ---- android/app/src/main/res/values/strings.xml | 6 +- android/ffi/src/lib.rs | 2 - android/ffi/src/models.rs | 14 +-- core/src/local/models.rs | 5 - core/src/local/schema.rs | 16 +++- core/src/local/store.rs | 37 +++----- core/src/sync/compat.rs | 4 +- core/src/sync/pull.rs | 7 +- core/src/sync/push.rs | 28 +++--- core/src/sync/wire.rs | 6 -- frontend/src/adapters/repo.ts | 5 +- frontend/src/adapters/rest.ts | 1 - frontend/src/components/FilterBar.vue | 11 +-- frontend/src/components/NoteCard.vue | 44 +++++---- frontend/src/components/NoteEditor.vue | 91 ++++++++----------- frontend/src/notes/facets.ts | 4 - frontend/src/stores/notes.ts | 11 +-- src/thoughtsync/models/note.py | 2 - src/thoughtsync/models/note_item.py | 6 +- src/thoughtsync/models/saved_filter.py | 2 +- src/thoughtsync/notes/__init__.py | 22 +---- src/thoughtsync/notes/import_export.py | 35 ++++--- src/thoughtsync/saved_filters.py | 1 - src/thoughtsync/sync.py | 12 ++- tests/test_notes.py | 10 +- 34 files changed, 240 insertions(+), 323 deletions(-) create mode 100644 alembic/versions/0025_drop_note_kind.py delete mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteKind.kt diff --git a/alembic/versions/0025_drop_note_kind.py b/alembic/versions/0025_drop_note_kind.py new file mode 100644 index 0000000..08fd2d0 --- /dev/null +++ b/alembic/versions/0025_drop_note_kind.py @@ -0,0 +1,35 @@ +"""drop notes.kind — a checklist is something a note HAS (M13 step 2) + +Revision ID: 0025 +Revises: 0024 +Create Date: 2026-08-22 + +`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared +against a hardcoded ("text", "list") tuple in six places. `note_items` was always an +ordinary child table keyed by note_id, serialization always emitted `items` whatever +the kind, and the Android editor already toggled between the two losslessly. The +storage has modelled "a body plus optional checkable items" the whole time; only the +gates forbade it. + +Nothing is lost. Items were already rows in their own table, and a note that was +`kind = 'list'` keeps every one of them — it just stops being a different sort of +thing from the note next to it. +""" +from alembic import op +import sqlalchemy as sa + +revision = "0025" +down_revision = "0024" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("notes", "kind") + + +def downgrade() -> None: + # server_default so existing rows get a value; every note comes back as 'text', + # which is right — a restored note with items would previously have hidden its + # body, and there is no record of which ones were once lists. + op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text")) 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 f88d0f8..4ee1ef2 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 = { kind, title, content -> - board.create(kind, title, content) + onSave = { title, content -> + board.create(title, 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 037310e..146dd27 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 @@ -50,9 +50,6 @@ sealed interface Destination { ) : Destination } -/** What kind of thing the compose sheet is making. */ -enum class DraftKind { NOTE, LIST } - /** Everything the board renders from, in one immutable snapshot. */ data class BoardState( val destination: Destination = Destination.Notes, @@ -198,7 +195,6 @@ class BoardViewModel( * mistake worth interrupting someone over. */ fun create( - kind: DraftKind, title: String, content: String, ) { @@ -210,7 +206,7 @@ class BoardViewModel( state = state.copy(saving = true) state = try { - val created = withContext(Dispatchers.IO) { core.createNote(draft(kind, cleanTitle, cleanContent)) } + val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanTitle, 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 @@ -306,9 +302,6 @@ class BoardViewModel( is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color)) - EditorAction.ToggleKind -> - edit(id, NoteEdit.Kind(if (note.kind == KIND_LIST) KIND_TEXT else KIND_LIST)) - // Pinning re-sorts the board rather than emptying it, and on a phone // you often pin while still reading — so unlike the three below, it // deliberately leaves the editor open. @@ -331,6 +324,10 @@ class BoardViewModel( null } + // An empty first item: the checklist editor appears the moment the note + // has one, and an empty row is what someone can type straight into. + EditorAction.AddChecklist -> mutate { it.addItem(id, "") } + is EditorAction.AddItem -> action.text.trim().takeIf { it.isNotEmpty() }?.let { text -> mutate { it.addItem(id, text) } @@ -468,25 +465,11 @@ private fun query( ) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null) private fun draft( - kind: DraftKind, title: String, content: String, ): NoteDraft = - when (kind) { - // Body left to carry 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. - DraftKind.NOTE -> - NoteDraft(title = title, body = content, color = DEFAULT_COLOR, kind = null, items = null) - // One line per item. At CAPTURE time the whole list is already in your - // head, so typing it in one go beats a tap between each row; the editor - // has the per-row control for when the list is being revised instead. - DraftKind.LIST -> - NoteDraft( - title = title, - body = "", - color = DEFAULT_COLOR, - kind = KIND_LIST, - items = content.lines().map { it.trim() }.filter { it.isNotEmpty() }, - ) - } + // 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) 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 721b275..1894b32 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 @@ -58,18 +58,17 @@ import com.fabledsword.thoughtsync.R fun ComposeSheet( saving: Boolean, onDismiss: () -> Unit, - onSave: (DraftKind, String, String) -> Unit, + onSave: (String, 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 kind by rememberSaveable { mutableStateOf(DraftKind.NOTE) } 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(kind, title, content) else onDismiss() } + val leave = { if (written) onSave(title, 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 @@ -79,7 +78,7 @@ fun ComposeSheet( // 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(kind, title, content) } + FlushOnStop { if (written) onSave(title, content) } ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) { Column( @@ -91,19 +90,8 @@ fun ComposeSheet( .navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = kind == DraftKind.NOTE, - onClick = { kind = DraftKind.NOTE }, - label = { Text(stringResource(R.string.compose_kind_note)) }, - ) - FilterChip( - selected = kind == DraftKind.LIST, - onClick = { kind = DraftKind.LIST }, - label = { Text(stringResource(R.string.compose_kind_list)) }, - ) - } - + // 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 }, @@ -115,19 +103,14 @@ fun ComposeSheet( value = content, onValueChange = { content = it }, modifier = Modifier.focusRequester(contentFocus), - hint = - if (kind == DraftKind.LIST) { - R.string.compose_list_hint - } else { - R.string.compose_body_hint - }, + hint = R.string.compose_body_hint, minLines = MIN_CONTENT_LINES, ) SheetActions( canSave = !saving && written, onDiscard = onDismiss, - onSave = { onSave(kind, title, content) }, + onSave = { onSave(title, 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 c500b84..d7999f7 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 @@ -31,13 +31,13 @@ sealed interface EditorAction { ) : EditorAction /** - * Note ⇄ checklist. + * Give this note a checklist. * - * Only `kind` changes: the body text and any existing items both stay where - * they are, so switching back and forth is lossless and a mis-tap costs - * nothing. + * Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2), + * so nothing moves and nothing is swapped: the body stays exactly where it is and + * the note gains a first, empty item for someone to type into. */ - data object ToggleKind : EditorAction + data object AddChecklist : EditorAction data class SetPinned( val pinned: Boolean, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index 4fbb146..6f6c557 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -84,15 +84,16 @@ fun EditorBottomBar( contentDescription = stringResource(R.string.editor_reminder), ) } - IconButton(onClick = { onAction(EditorAction.ToggleKind) }) { - val list = note.kind == KIND_LIST - Icon( - if (list) Icons.Filled.Create else Icons.AutoMirrored.Filled.List, - contentDescription = - stringResource( - if (list) R.string.editor_make_note else R.string.editor_make_list, - ), - ) + // Adds the first checklist item, which is what makes the checklist + // editor appear. Hidden once the note already has one — there is nothing + // left to add that the checklist's own "+" row doesn't do better. + if (note.items.isEmpty()) { + IconButton(onClick = { onAction(EditorAction.AddChecklist) }) { + Icon( + Icons.AutoMirrored.Filled.List, + contentDescription = stringResource(R.string.editor_add_checklist), + ) + } } } 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 91f290d..4f4af64 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 @@ -64,9 +64,8 @@ fun NoteCard( Spacer(Modifier.height(4.dp)) } - if (note.kind == KIND_LIST) { - Checklist(items = note.items) - } else if (note.body.isNotBlank()) { + // Both, in order — a note can carry a body AND a checklist (M13 step 2). + if (note.body.isNotBlank()) { Text( text = note.body, style = MaterialTheme.typography.bodyMedium, @@ -74,6 +73,10 @@ fun NoteCard( overflow = TextOverflow.Ellipsis, ) } + if (note.items.isNotEmpty()) { + if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp)) + 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. 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 9281fda..87e1bad 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 @@ -150,16 +150,18 @@ fun NoteEditorScreen( bold = true, ) - if (note.kind == KIND_LIST) { + EditorField( + value = body, + onValueChange = { body = it }, + hint = R.string.editor_body_hint, + enabled = !readOnly, + minLines = MIN_BODY_LINES, + ) + + // Below the body, not instead of it, and only once the note has items — + // the toolbar's add-checklist action is what puts the first one there. + if (note.items.isNotEmpty()) { ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction) - } else { - EditorField( - value = body, - onValueChange = { body = it }, - hint = R.string.editor_body_hint, - enabled = !readOnly, - minLines = MIN_BODY_LINES, - ) } if (note.labels.isNotEmpty()) { diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteKind.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteKind.kt deleted file mode 100644 index 4f20da4..0000000 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteKind.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.fabledsword.thoughtsync.ui - -/** - * The core's `kind` vocabulary, which the UI has to match exactly. - * - * Shared rather than repeated because it was already living in three places — the - * card deciding whether to draw checkboxes, the editor deciding which field to - * show, and the view model deciding what to create — and a typo in any one of them - * would silently render a checklist as a paragraph rather than fail. - * - * Strings and not an enum: this is a value the STORE owns, arriving from a server - * that may be newer than this client, and an unrecognised kind has to fall through - * to "render it as a note" rather than throw. - */ -internal const val KIND_TEXT = "text" -internal const val KIND_LIST = "list" diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 04e5f28..9978d83 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -10,11 +10,8 @@ New note - Note - List Title Take a note… - One item per line Discard Save @@ -42,13 +39,12 @@ Open note Back to notes Title + Add a checklist Note Add item Remove item Remove label Set a reminder - Make a checklist - Switch to a note More actions Pin Unpin diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 190642b..c88a2c8 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -573,7 +573,6 @@ mod tests { title: title.to_string(), body: body.to_string(), color: "default".to_string(), - kind: None, items: None, } } @@ -677,7 +676,6 @@ mod tests { title: "Packing".to_string(), body: String::new(), color: "default".to_string(), - kind: Some("list".to_string()), items: Some(vec!["socks".to_string()]), }) .expect("create"); diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index de42191..a202abd 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -35,7 +35,6 @@ pub struct Note { pub display_title: String, pub body: String, pub color: String, - pub kind: String, pub position: i64, pub pinned: bool, pub archived: bool, @@ -134,7 +133,6 @@ impl From for Note { display_title, body, color, - kind, position, pinned, archived, @@ -155,7 +153,6 @@ impl From for Note { display_title, body, color, - kind, position, pinned, archived, @@ -294,7 +291,6 @@ pub struct NoteQuery { pub struct NoteFacets { pub q: Option, pub color: Option, - pub kind: Option, pub label: Option>, pub has_reminder: Option, pub has_attachment: Option, @@ -324,7 +320,6 @@ impl From for core_models::Facets { let NoteFacets { q, color, - kind, label, has_reminder, has_attachment, @@ -334,7 +329,6 @@ impl From for core_models::Facets { core_models::Facets { q, color, - kind, label, has_reminder, has_attachment, @@ -351,8 +345,8 @@ pub struct NoteDraft { pub body: String, /// "default" unless the user picked a colour. pub color: String, - pub kind: Option, - /// Checklist lines, for `kind = "checklist"`. + /// Checklist lines. A note can carry both a body and items (M13 step 2), so this + /// is not an alternative to `body` — it is an addition to it. pub items: Option>, } @@ -362,14 +356,12 @@ impl From for core_models::NoteCreateInput { title, body, color, - kind, items, } = value; core_models::NoteCreateInput { title, body, color, - kind, items, } } @@ -389,7 +381,6 @@ pub enum NoteEdit { ClearTitle, Body { value: String }, Color { value: String }, - Kind { value: String }, Pinned { value: bool }, Archived { value: bool }, RemindAt { value: String }, @@ -412,7 +403,6 @@ impl NoteEdit { NoteEdit::ClearTitle => ("title", Value::Null), NoteEdit::Body { value } => ("body", Value::String(value)), NoteEdit::Color { value } => ("color", Value::String(value)), - NoteEdit::Kind { value } => ("kind", Value::String(value)), NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)), NoteEdit::Archived { value } => ("archived", Value::Bool(value)), NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)), diff --git a/core/src/local/models.rs b/core/src/local/models.rs index fa22918..33cfc47 100644 --- a/core/src/local/models.rs +++ b/core/src/local/models.rs @@ -14,7 +14,6 @@ pub struct Note { pub display_title: String, pub body: String, pub color: String, - pub kind: String, pub position: i64, pub pinned: bool, pub archived: bool, @@ -136,8 +135,6 @@ pub struct NoteCreateInput { #[serde(default = "default_color")] pub color: String, #[serde(default)] - pub kind: Option, - #[serde(default)] pub items: Option>, } @@ -162,8 +159,6 @@ pub struct Facets { #[serde(default)] pub color: Option, #[serde(default)] - pub kind: Option, - #[serde(default)] pub label: Option>, #[serde(default)] pub has_reminder: Option, diff --git a/core/src/local/schema.rs b/core/src/local/schema.rs index 5927324..dbc3af8 100644 --- a/core/src/local/schema.rs +++ b/core/src/local/schema.rs @@ -14,7 +14,7 @@ CREATE TABLE notes ( title TEXT, body TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT 'default', - kind TEXT NOT NULL DEFAULT 'text', -- 'text' | 'list' + kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop position INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, @@ -160,6 +160,16 @@ CREATE TABLE prefs ( ); "#; +// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something +// a note IS — the column was a mode flag with no enum and no constraint behind it, +// and `note_items` was never tied to it. Dropping it loses nothing: a note that was +// 'list' keeps every one of its items. +// +// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it. +const SCHEMA_V6: &str = r#" +ALTER TABLE notes DROP COLUMN kind; +"#; + /// Bring the database up to the latest schema. Idempotent. pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch("PRAGMA foreign_keys = ON;")?; @@ -184,5 +194,9 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch(SCHEMA_V5)?; conn.execute_batch("PRAGMA user_version = 5;")?; } + if version < 6 { + conn.execute_batch(SCHEMA_V6)?; + conn.execute_batch("PRAGMA user_version = 6;")?; + } Ok(()) } diff --git a/core/src/local/store.rs b/core/src/local/store.rs index d3fa9a5..07bef6b 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -139,7 +139,7 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result rusqlite::Result { let mut note = conn.query_row( - "SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at + "SELECT id, title, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at FROM notes WHERE id = ?1", [id], |r| { @@ -152,20 +152,19 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result { display_title: dt, body, color: r.get(3)?, - kind: r.get(4)?, - position: r.get(5)?, - pinned: r.get(6)?, - archived: r.get(7)?, - trashed: r.get(8)?, - deleted_at: r.get(13)?, - remind_at: r.get(9)?, - recurrence: r.get(10)?, + 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)?, labels: Vec::new(), items: Vec::new(), attachments: Vec::new(), previews: Vec::new(), - created_at: r.get(11)?, - updated_at: r.get(12)?, + created_at: r.get(10)?, + updated_at: r.get(11)?, }) }, )?; @@ -277,10 +276,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result rusqlite::Resu let id = new_id(); let ts = now(); let title = normalize_title(&input.title); - let kind = input.kind.clone().unwrap_or_else(|| "text".to_string()); 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, kind, position, created_at, updated_at, dirty) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)", - params![id, title, input.body, input.color, kind, position, ts], + "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], )?; if let Some(items) = &input.items { for (i, text) in items.iter().enumerate() { @@ -411,11 +405,6 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?; } } - "kind" => { - if let Some(s) = v.as_str() { - conn.execute("UPDATE notes SET kind = ?1 WHERE id = ?2", params![s, id])?; - } - } "pinned" => { if let Some(b) = v.as_bool() { conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?; diff --git a/core/src/sync/compat.rs b/core/src/sync/compat.rs index e9a5908..8593448 100644 --- a/core/src/sync/compat.rs +++ b/core/src/sync/compat.rs @@ -19,11 +19,11 @@ use serde::{Deserialize, Serialize}; /// The sync wire protocol this client speaks. -pub const CLIENT_PROTOCOL_VERSION: u32 = 1; +pub const CLIENT_PROTOCOL_VERSION: u32 = 2; /// The oldest server protocol this client can drive — the symmetric half of the /// server's `min_client_protocol_version`. -pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 1; +pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2; /// Capabilities without which syncing is meaningless, so their absence BLOCKS the /// link rather than degrading it. diff --git a/core/src/sync/pull.rs b/core/src/sync/pull.rs index 3b33c85..b1bff40 100644 --- a/core/src/sync/pull.rs +++ b/core/src/sync/pull.rs @@ -240,15 +240,14 @@ 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, kind, position, pinned, archived, + "INSERT INTO notes (id, title, 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, ?15, 0) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, 0) ON CONFLICT(id) DO UPDATE SET title = excluded.title, body = excluded.body, color = excluded.color, - kind = excluded.kind, position = excluded.position, pinned = excluded.pinned, archived = excluded.archived, @@ -264,7 +263,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { note.title, note.body, note.color, - note.kind, note.position, note.pinned, note.archived, @@ -501,7 +499,6 @@ mod tests { title: Some("Title".into()), body: "Body".into(), color: "default".into(), - kind: "text".into(), position: 0, pinned: false, archived: false, diff --git a/core/src/sync/push.rs b/core/src/sync/push.rs index bb2e563..f4ca47b 100644 --- a/core/src/sync/push.rs +++ b/core/src/sync/push.rs @@ -69,7 +69,6 @@ pub struct Change { #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub pinned: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -102,7 +101,6 @@ impl Change { title: None, body: None, color: None, - kind: None, pinned: None, archived: None, trashed: None, @@ -202,7 +200,6 @@ fn collect_labels(conn: &Connection, out: &mut Vec, limit: usize) -> rus edited_at: r.get(3)?, title: None, body: None, - kind: None, pinned: None, archived: None, trashed: None, @@ -240,7 +237,6 @@ struct NoteRow { title: Option, body: String, color: String, - kind: String, position: i64, pinned: bool, archived: bool, @@ -253,7 +249,7 @@ struct NoteRow { fn note_row(conn: &Connection, id: &str) -> rusqlite::Result { conn.query_row( - "SELECT title, body, color, kind, position, pinned, archived, trashed, + "SELECT title, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at FROM notes WHERE id = ?1", params![id], @@ -262,15 +258,14 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result { title: r.get(0)?, body: r.get(1)?, color: r.get(2)?, - kind: r.get(3)?, - position: r.get(4)?, - pinned: r.get::<_, i64>(5)? != 0, - archived: r.get::<_, i64>(6)? != 0, - trashed: r.get::<_, i64>(7)? != 0, - remind_at: r.get(8)?, - recurrence: r.get(9)?, - created_at: r.get(10)?, - updated_at: r.get(11)?, + 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)?, }) }, ) @@ -312,7 +307,6 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result { title: row.title, body: Some(row.body), color: Some(row.color), - kind: Some(row.kind), pinned: Some(row.pinned), archived: Some(row.archived), trashed: Some(row.trashed), @@ -535,9 +529,9 @@ mod tests { fn seed_note(conn: &Connection, id: &str, dirty: i64) { conn.execute( - "INSERT INTO notes (id, title, body, color, kind, position, pinned, archived, + "INSERT INTO notes (id, title, body, color, position, pinned, archived, trashed, created_at, updated_at, sync_revision, dirty) - VALUES (?1, 'T', 'B', 'default', 'text', 0, 0, 0, 0, + VALUES (?1, 'T', '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 92aedef..8a80dbb 100644 --- a/core/src/sync/wire.rs +++ b/core/src/sync/wire.rs @@ -29,8 +29,6 @@ pub struct Note { pub body: String, #[serde(default = "default_color")] pub color: String, - #[serde(default = "default_kind")] - pub kind: String, #[serde(default)] pub position: i64, #[serde(default)] @@ -157,10 +155,6 @@ fn default_color() -> String { "default".to_string() } -fn default_kind() -> String { - "text".to_string() -} - fn default_mime() -> String { "application/octet-stream".to_string() } diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 0b319e0..2efff6e 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -10,7 +10,7 @@ // stays in the stores — the repo is data access only. import type { NoteColor } from "../notes/colors"; -import type { Note, NoteFacets, NoteView, NoteKind, NoteRevision } from "../stores/notes"; +import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes"; import type { Label } from "../stores/labels"; import type { SavedFilter } from "../stores/savedFilters"; import type { Device } from "../stores/devices"; @@ -35,13 +35,12 @@ export interface NoteCreateInput { title: string; body: string; color: NoteColor; - kind?: NoteKind; items?: string[]; } // The mutable subset of a note (PATCH /api/notes/:id). export type NoteChanges = Partial< - Pick + Pick >; export interface ChecklistItemChanges { diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 9040431..4b161a5 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -32,7 +32,6 @@ function notesQuery(q: NoteListQuery): string { for (const id of q.facets?.label ?? []) if (id) params.append("label", id); if (q.facets?.q) params.set("q", q.facets.q); if (q.facets?.color) params.set("color", q.facets.color); - if (q.facets?.kind) params.set("kind", q.facets.kind); if (q.facets?.has_reminder) params.set("has_reminder", "true"); if (q.facets?.has_attachment) params.set("has_attachment", "true"); if (q.facets?.created_after) params.set("created_after", q.facets.created_after); diff --git a/frontend/src/components/FilterBar.vue b/frontend/src/components/FilterBar.vue index e04406a..d448977 100644 --- a/frontend/src/components/FilterBar.vue +++ b/frontend/src/components/FilterBar.vue @@ -11,7 +11,7 @@ import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor import Icon from "./Icon.vue"; // A dead-simple facet bar over the board: text search + color + labels + has-reminder -// + has-attachment + kind + created-date range. The URL query IS the state, so a +// + has-attachment + created-date range. The URL query IS the state, so a // filtered board is a shareable lens and a saved view is just a link. const route = useRoute(); const router = useRouter(); @@ -35,9 +35,6 @@ function clearAll() { function setColor(c: NoteColor) { patch({ color: facets.value.color === c ? undefined : c }); } -function setKind(k: "text" | "list") { - patch({ kind: facets.value.kind === k ? undefined : k }); -} function toggleLabel(id: string) { const cur = facets.value.label ?? []; const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; @@ -164,12 +161,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b - -
diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 6f95476..c8c5916 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -210,28 +210,16 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
- - - - + +
(props.note?.color ?? "default"); const labelList = ref(props.note ? [...props.note.labels] : []); -const createKind = ref<"text" | "list">("text"); // compose-only list toggle +// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2) +// rather than BEING one, so this is a view flag, not a property of the note: it turns +// on when the note already carries items, and when someone asks for one. +const checklistOpen = ref(false); const saving = ref(false); const root = ref(null); const bodyInput = ref(null); @@ -51,14 +54,13 @@ const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() const richEnabled = computed(() => !isCreate.value || hasContent.value); // A synthetic note for compose mode (before anything is persisted), so the shared -// template can read attachments/items/kind/remind_at uniformly. +// template can read attachments/items/remind_at uniformly. const draftNote = computed(() => ({ id: "", title: title.value.trim() || null, display_title: "", body: body.value, color: color.value, - kind: createKind.value, position: 0, pinned: false, archived: false, @@ -78,13 +80,16 @@ const liveNote = computed(() => ? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value) : draftNote.value, ); -// Only edit-mode list notes render the interactive checklist; compose-list types -// lines into the textarea (they become items on create). -const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list"); -const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list")); -const bodyPlaceholder = computed(() => - isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)", +// The checklist renders once the note has items, or once someone has asked for one. +// It sits BELOW the body rather than instead of it — a note can carry both, which is +// the whole point of the merge. +// +// Items need a persisted note to hang off, so this is a rich action like attaching a +// file: in compose it waits for the draft to be saved. +const showChecklist = computed( + () => !isCreate.value && (liveNote.value.items.length > 0 || checklistOpen.value), ); +const bodyPlaceholder = "Take a note…"; // Keep local state in sync when the edited note changes (modal reused for another note). watch( @@ -101,17 +106,7 @@ watch( // ---- persistence ---- async function createFromFields(): Promise { - let created: Note; - if (createKind.value === "list") { - const items = body.value - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - created = await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items }); - body.value = ""; // the lines moved into checklist items - } else { - created = await notes.create({ title: title.value, body: body.value, color: color.value }); - } + const created = await notes.create({ title: title.value, body: body.value, color: color.value }); noteId.value = created.id; baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor }; } @@ -139,7 +134,7 @@ async function flush(): Promise { return; } const b = baseline.value; - const nextBody = showChecklist.value ? b.body : body.value; + const nextBody = body.value; const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color; if (!changed) return; saving.value = true; @@ -157,7 +152,7 @@ function resetCompose(): void { body.value = ""; color.value = "default"; labelList.value = []; - createKind.value = "text"; + checklistOpen.value = false; baseline.value = { title: null, body: "", color: "default" }; uploadError.value = ""; } @@ -301,29 +296,16 @@ function labelChip(c: string): string { return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default; } -// ---- kind toggle: compose = local flag, edit = convert the existing note ---- -async function toggleKind() { - if (isCreate.value) { - createKind.value = createKind.value === "list" ? "text" : "list"; - bodyInput.value?.focus(); - return; - } - const id = noteId.value as string; - if (liveNote.value.kind === "list") { - await notes.setKind(id, "text"); - return; - } - const lines = body.value - .split("\n") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - for (const line of lines) await notes.addItem(id, line); - if (lines.length > 0) { - body.value = ""; - await notes.saveEdit(id, { title: title.value, body: "", color: color.value }); - baseline.value = { title: title.value.trim() || null, body: "", color: color.value }; - } - await notes.setKind(id, "list"); +// ---- add a checklist ---- +// +// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its +// body and gains a place to put items. Persists the draft first for the same reason +// attaching a file does — an item needs a note to belong to. +async function addChecklist() { + if (checklistOpen.value) return; + const id = await ensureDraft(); + if (!id) return; + checklistOpen.value = true; } // ---- attachments ---- @@ -563,7 +545,6 @@ function revPreview(rev: NoteRevision): string { />