A checklist is something a note has, not something a note is
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s

`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.
This commit is contained in:
2026-08-22 12:53:53 -04:00
parent 229076c82d
commit c46a4a7709
34 changed files with 240 additions and 323 deletions
-5
View File
@@ -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<String>,
#[serde(default)]
pub items: Option<Vec<String>>,
}
@@ -162,8 +159,6 @@ pub struct Facets {
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
+15 -1
View File
@@ -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(())
}
+13 -24
View File
@@ -139,7 +139,7 @@ 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, 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<Note> {
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<Vec<Note
sql.push_str(" AND color = ?");
binds.push(c.to_string());
}
if let Some(k) = f.kind.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND kind = ?");
binds.push(k.to_string());
}
if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL");
}
@@ -356,16 +351,15 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> 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])?;
+2 -2
View File
@@ -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.
+2 -5
View File
@@ -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,
+11 -17
View File
@@ -69,7 +69,6 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pinned: Option<bool>,
#[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<Change>, 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<String>,
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<NoteRow> {
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<NoteRow> {
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<Change> {
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],
)
-6
View File
@@ -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()
}