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 { />