0.2.0 — a notebook in your pocket, ready to be hosted #3
@@ -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"))
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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"
|
||||
@@ -10,11 +10,8 @@
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
<string name="compose_kind_note">Note</string>
|
||||
<string name="compose_kind_list">List</string>
|
||||
<string name="compose_title_hint">Title</string>
|
||||
<string name="compose_body_hint">Take a note…</string>
|
||||
<string name="compose_list_hint">One item per line</string>
|
||||
<string name="compose_discard">Discard</string>
|
||||
<string name="compose_save">Save</string>
|
||||
|
||||
@@ -42,13 +39,12 @@
|
||||
<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>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove label</string>
|
||||
<string name="editor_reminder">Set a reminder</string>
|
||||
<string name="editor_make_list">Make a checklist</string>
|
||||
<string name="editor_make_note">Switch to a note</string>
|
||||
<string name="editor_more">More actions</string>
|
||||
<string name="editor_pin">Pin</string>
|
||||
<string name="editor_unpin">Unpin</string>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<core_models::Note> for Note {
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
@@ -155,7 +153,6 @@ impl From<core_models::Note> for Note {
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
@@ -294,7 +291,6 @@ pub struct NoteQuery {
|
||||
pub struct NoteFacets {
|
||||
pub q: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub label: Option<Vec<String>>,
|
||||
pub has_reminder: Option<bool>,
|
||||
pub has_attachment: Option<bool>,
|
||||
@@ -324,7 +320,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
let NoteFacets {
|
||||
q,
|
||||
color,
|
||||
kind,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
@@ -334,7 +329,6 @@ impl From<NoteFacets> 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<String>,
|
||||
/// 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<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -362,14 +356,12 @@ impl From<NoteDraft> 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)),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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])?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
Pick<Note, "title" | "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
>;
|
||||
|
||||
export interface ChecklistItemChanges {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
|
||||
Has attachment
|
||||
</button>
|
||||
<button type="button" :class="[chipBase, facets.kind === 'list' ? chipOn : chipOff]" @click="setKind('list')">
|
||||
Lists
|
||||
</button>
|
||||
<button type="button" :class="[chipBase, facets.kind === 'text' ? chipOn : chipOff]" @click="setKind('text')">
|
||||
Notes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -210,28 +210,16 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" />
|
||||
</div>
|
||||
|
||||
<!-- Checklist notes can't nest interactive controls in a <button>, so use a
|
||||
focusable div; text notes keep a semantic button. -->
|
||||
<template v-if="note.kind === 'list'">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
@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>
|
||||
<NoteChecklist class="mt-1" :note-id="note.id" :items="note.items" @click="emit('open', note)" />
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="block w-full cursor-text rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
|
||||
<!-- One render path: every note is a body plus, maybe, checkable items.
|
||||
A focusable div rather than a <button>, because a checklist nests interactive
|
||||
controls and those cannot live inside a button — and the card is the same
|
||||
shape whether or not it happens to carry items today. -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
@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 }}
|
||||
@@ -239,10 +227,20 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
<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.attachments.length" class="text-sm italic text-neutral-400">
|
||||
<p
|
||||
v-if="!note.title && !note.body && !note.items.length && !note.attachments.length"
|
||||
class="text-sm italic text-neutral-400"
|
||||
>
|
||||
Empty note
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<NoteChecklist
|
||||
v-if="note.items.length"
|
||||
:class="note.body || note.title ? 'mt-2' : ''"
|
||||
:note-id="note.id"
|
||||
:items="note.items"
|
||||
@click="emit('open', note)"
|
||||
/>
|
||||
|
||||
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
|
||||
@@ -31,7 +31,10 @@ 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] : []);
|
||||
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<HTMLElement | null>(null);
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(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<Note>(() => ({
|
||||
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<Note>(() =>
|
||||
? (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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-if="!showChecklist"
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
@@ -571,7 +552,14 @@ function revPreview(rev: NoteRevision): string {
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@keydown="onBodyKeydown"
|
||||
/>
|
||||
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
|
||||
<!-- Below the body, not instead of it. -->
|
||||
<NoteChecklist
|
||||
v-if="showChecklist"
|
||||
class="py-1"
|
||||
:note-id="liveNote.id"
|
||||
:items="liveNote.items"
|
||||
editable
|
||||
/>
|
||||
|
||||
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
||||
<span
|
||||
@@ -681,13 +669,12 @@ function revPreview(rev: NoteRevision): string {
|
||||
</button>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
|
||||
<button
|
||||
v-if="!liveNote.trashed"
|
||||
v-if="richEnabled && !liveNote.trashed && !showChecklist"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''"
|
||||
:title="isListMode ? 'Switch to a note' : 'Make a checklist'"
|
||||
:aria-pressed="isListMode"
|
||||
@click="toggleKind"
|
||||
title="Add a checklist"
|
||||
aria-label="Add a checklist"
|
||||
@click="addChecklist"
|
||||
>
|
||||
<Icon name="checkbox" />
|
||||
</button>
|
||||
|
||||
@@ -17,8 +17,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
|
||||
if (text) f.q = text;
|
||||
const color = one(q.color);
|
||||
if (color) f.color = color;
|
||||
const kind = one(q.kind);
|
||||
if (kind === "text" || kind === "list") f.kind = kind;
|
||||
if (labels.length) f.label = labels;
|
||||
if (one(q.has_reminder) === "true") f.has_reminder = true;
|
||||
if (one(q.has_attachment) === "true") f.has_attachment = true;
|
||||
@@ -33,7 +31,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
|
||||
const q: LocationQueryRaw = {};
|
||||
if (f.q) q.q = f.q;
|
||||
if (f.color) q.color = f.color;
|
||||
if (f.kind) q.kind = f.kind;
|
||||
if (f.label?.length) q.label = f.label;
|
||||
if (f.has_reminder) q.has_reminder = "true";
|
||||
if (f.has_attachment) q.has_attachment = "true";
|
||||
@@ -47,7 +44,6 @@ export function facetCount(f: NoteFacets): number {
|
||||
let n = 0;
|
||||
if (f.q) n++;
|
||||
if (f.color) n++;
|
||||
if (f.kind) n++;
|
||||
n += f.label?.length ?? 0;
|
||||
if (f.has_reminder) n++;
|
||||
if (f.has_attachment) n++;
|
||||
|
||||
@@ -5,14 +5,11 @@ import { useUiStore } from "./ui";
|
||||
import type { NoteColor } from "../notes/colors";
|
||||
|
||||
export type NoteView = "active" | "archived" | "trash";
|
||||
export type NoteKind = "text" | "list";
|
||||
|
||||
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved
|
||||
// view's stored params). All optional; empty = the plain, unfiltered board.
|
||||
export interface NoteFacets {
|
||||
q?: string;
|
||||
color?: string;
|
||||
kind?: NoteKind;
|
||||
label?: string[];
|
||||
has_reminder?: boolean;
|
||||
has_attachment?: boolean;
|
||||
@@ -71,7 +68,6 @@ export interface Note {
|
||||
display_title: string;
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
kind: NoteKind;
|
||||
position: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
@@ -141,8 +137,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
title: string;
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
kind?: NoteKind;
|
||||
items?: string[];
|
||||
items?: string[];
|
||||
}): Promise<Note> {
|
||||
const note = await repo.notes.create(input);
|
||||
reconcile(note);
|
||||
@@ -152,7 +147,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
async function mutate(
|
||||
id: string,
|
||||
changes: Partial<
|
||||
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
Pick<Note, "title" | "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
>,
|
||||
): Promise<void> {
|
||||
reconcile(await repo.notes.update(id, changes));
|
||||
@@ -165,7 +160,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
|
||||
};
|
||||
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
||||
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
|
||||
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
|
||||
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
|
||||
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
|
||||
@@ -284,7 +278,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
setPinned,
|
||||
setArchived,
|
||||
setColor,
|
||||
setKind,
|
||||
setReminder,
|
||||
setRecurrence,
|
||||
completeReminder,
|
||||
|
||||
@@ -47,7 +47,6 @@ class Note(Base):
|
||||
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
||||
# 'text' (freeform body) or 'list' (a checklist of note_items).
|
||||
kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text")
|
||||
# Manual drag order (higher = earlier); 0 until the user reorders.
|
||||
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
||||
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||
@@ -78,7 +77,6 @@ class Note(Base):
|
||||
"display_title": self.display_title,
|
||||
"body": self.body,
|
||||
"color": self.color,
|
||||
"kind": self.kind,
|
||||
"position": self.position,
|
||||
"pinned": self.pinned,
|
||||
"archived": self.archived,
|
||||
|
||||
@@ -11,7 +11,11 @@ from . import Base
|
||||
|
||||
|
||||
class NoteItem(Base):
|
||||
"""A single checklist item within a note (only used when note.kind == 'list')."""
|
||||
"""A single checklist item on a note.
|
||||
|
||||
Any note can have them. There is no note "kind" gating this — a checklist is
|
||||
something a note HAS, not something a note IS (M13 step 2).
|
||||
"""
|
||||
|
||||
__tablename__ = "note_items"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from . import Base
|
||||
class SavedFilter(Base):
|
||||
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one
|
||||
click — e.g. "Yellow + #ideas". `params` is a JSON-encoded facet dict matching the
|
||||
GET /api/notes query (q/color/kind/labels/has_reminder/has_attachment/date range)."""
|
||||
GET /api/notes query (q/color/labels/has_reminder/has_attachment/date range)."""
|
||||
|
||||
__tablename__ = "saved_filters"
|
||||
|
||||
|
||||
@@ -101,7 +101,6 @@ async def list_notes():
|
||||
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
||||
label_params = request.args.getlist("label")
|
||||
color = request.args.get("color")
|
||||
kind = request.args.get("kind")
|
||||
has_reminder = coerce_bool(request.args.get("has_reminder"))
|
||||
has_attachment = coerce_bool(request.args.get("has_attachment"))
|
||||
query_text = (request.args.get("q") or "").strip()
|
||||
@@ -125,10 +124,6 @@ async def list_notes():
|
||||
if color not in NOTE_COLORS:
|
||||
return json_error("invalid color", 400)
|
||||
stmt = stmt.where(Note.color == color)
|
||||
if kind is not None:
|
||||
if kind not in ("text", "list"):
|
||||
return json_error("invalid kind", 400)
|
||||
stmt = stmt.where(Note.kind == kind)
|
||||
if has_reminder:
|
||||
stmt = stmt.where(Note.remind_at.is_not(None))
|
||||
if has_attachment:
|
||||
@@ -282,7 +277,6 @@ async def export_notes():
|
||||
"display_title": n.display_title,
|
||||
"body": n.body,
|
||||
"color": n.color,
|
||||
"kind": n.kind,
|
||||
"pinned": n.pinned,
|
||||
"archived": n.archived,
|
||||
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
|
||||
@@ -418,14 +412,11 @@ 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 ""
|
||||
kind = data.get("kind") if data.get("kind") in ("text", "list") else "text"
|
||||
# A checklist note's "content" is its items, not the body — so it's non-empty
|
||||
# when it has a title or at least one item (quick-add can create one in one shot).
|
||||
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
|
||||
if kind == "list":
|
||||
if not (title.strip() or item_texts):
|
||||
return json_error("note is empty", 400)
|
||||
elif is_empty_note(title, body):
|
||||
# Items are accepted on ANY note now — 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:
|
||||
return json_error("note is empty", 400)
|
||||
async with session_scope() as db:
|
||||
# New notes go to the top of the manual order.
|
||||
@@ -440,7 +431,6 @@ async def create_note():
|
||||
title=clean_title,
|
||||
display_title=derive_display_title(clean_title, body),
|
||||
body=body,
|
||||
kind=kind,
|
||||
color=normalize_color(data.get("color")),
|
||||
position=int(max_pos) + 1,
|
||||
)
|
||||
@@ -486,8 +476,6 @@ async def update_note(note_id: str):
|
||||
note.body = data["body"]
|
||||
if "color" in data:
|
||||
note.color = normalize_color(data["color"])
|
||||
if "kind" in data and data["kind"] in ("text", "list"):
|
||||
note.kind = data["kind"]
|
||||
if "pinned" in data:
|
||||
note.pinned = bool(data["pinned"])
|
||||
if "archived" in data:
|
||||
|
||||
@@ -52,11 +52,15 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
|
||||
fm.append(f"updated: {note.updated_at.isoformat() if note.updated_at else ''}")
|
||||
fm.append("---")
|
||||
fm.append("")
|
||||
if note.kind == "list":
|
||||
# Body and checklist are no longer alternatives — a note can carry both, so both
|
||||
# are written, body first, with a blank line between them when there is.
|
||||
if note.body:
|
||||
fm.append(note.body)
|
||||
if items:
|
||||
if note.body:
|
||||
fm.append("")
|
||||
for it in items:
|
||||
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
|
||||
else:
|
||||
fm.append(note.body)
|
||||
return "\n".join(fm) + "\n"
|
||||
|
||||
|
||||
@@ -100,7 +104,6 @@ def _native_spec(n: dict) -> dict:
|
||||
return {
|
||||
"title": n.get("title"),
|
||||
"body": n.get("body") or "",
|
||||
"kind": n.get("kind"),
|
||||
"color": n.get("color"),
|
||||
"pinned": bool(n.get("pinned")),
|
||||
"archived": bool(n.get("archived")),
|
||||
@@ -128,8 +131,10 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
|
||||
"""Normalize one Google Keep note (Takeout <note>.json) into the common import
|
||||
spec. `keep_dir` is the note JSON's folder, used to resolve attachment paths."""
|
||||
list_content = kn.get("listContent") if isinstance(kn.get("listContent"), list) else []
|
||||
is_list = bool(list_content)
|
||||
body = kn.get("textContent") or "" if not is_list else ""
|
||||
# Keep's own notes are one or the other, but its text was being DISCARDED whenever
|
||||
# a note also had list content, because the target model could only hold one.
|
||||
# It can hold both now, so both are kept.
|
||||
body = kn.get("textContent") or ""
|
||||
# Keep stores link annotations (e.g. shared URLs) separately from the text —
|
||||
# fold any URLs into the body so the content survives the move.
|
||||
urls = [
|
||||
@@ -155,7 +160,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
|
||||
return {
|
||||
"title": kn.get("title"),
|
||||
"body": body,
|
||||
"kind": "list" if is_list else "text",
|
||||
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
|
||||
"pinned": bool(kn.get("isPinned")),
|
||||
"archived": bool(kn.get("isArchived")),
|
||||
@@ -276,12 +280,9 @@ async def _create_imported_note(
|
||||
(nothing written) when the spec is empty."""
|
||||
title = (spec.get("title") or "").strip() or None
|
||||
body = spec.get("body") or ""
|
||||
kind = spec.get("kind") if spec.get("kind") in ("text", "list") else "text"
|
||||
items = spec.get("items") or []
|
||||
if kind == "list":
|
||||
if not (title or any((it.get("text") or "").strip() for it in items)):
|
||||
return False
|
||||
elif is_empty_note(title, body):
|
||||
has_items = any((it.get("text") or "").strip() for it in items)
|
||||
if is_empty_note(title, body) and not has_items:
|
||||
return False
|
||||
|
||||
note = Note(
|
||||
@@ -289,7 +290,6 @@ async def _create_imported_note(
|
||||
title=title,
|
||||
display_title=derive_display_title(title, body),
|
||||
body=body,
|
||||
kind=kind,
|
||||
color=normalize_color(spec.get("color")),
|
||||
pinned=bool(spec.get("pinned")),
|
||||
archived=bool(spec.get("archived")),
|
||||
@@ -310,11 +310,10 @@ async def _create_imported_note(
|
||||
db.add(note)
|
||||
await db.flush() # assign note.id before items/labels/attachments/links
|
||||
|
||||
if kind == "list":
|
||||
for pos, it in enumerate(items):
|
||||
text = (it.get("text") or "").strip()
|
||||
if text:
|
||||
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
|
||||
for pos, it in enumerate(items):
|
||||
text = (it.get("text") or "").strip()
|
||||
if text:
|
||||
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
|
||||
|
||||
# Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the
|
||||
# body are handled by _reconcile_tags below, same as a normal create.
|
||||
|
||||
@@ -19,7 +19,6 @@ NAME_CAP = 100
|
||||
_ALLOWED_PARAM_KEYS = {
|
||||
"q",
|
||||
"color",
|
||||
"kind",
|
||||
"label", # matches the repeatable ?label= query param (stored as an array)
|
||||
"has_reminder",
|
||||
"has_attachment",
|
||||
|
||||
@@ -55,8 +55,15 @@ 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.
|
||||
SYNC_PROTOCOL_VERSION = 1
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 1
|
||||
# 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.
|
||||
#
|
||||
# `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.
|
||||
SYNC_PROTOCOL_VERSION = 2
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 2
|
||||
|
||||
# Named capabilities beyond the base protocol. An ADDITIVE change earns a name
|
||||
# here rather than a min-version bump, so a newer client meeting an older server
|
||||
@@ -194,7 +201,6 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||
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.kind = ch["kind"] if ch.get("kind") in ("text", "list") else "text"
|
||||
note.pinned = bool(ch.get("pinned"))
|
||||
note.archived = bool(ch.get("archived"))
|
||||
if ch.get("trashed"):
|
||||
|
||||
+6
-4
@@ -317,9 +317,13 @@ def test_usec_to_dt():
|
||||
assert _usec_to_dt(None) is None
|
||||
|
||||
|
||||
def test_keep_spec_list_note():
|
||||
def test_keep_spec_list_note_keeps_its_text_too():
|
||||
# Keep's own notes carry one or the other, but its textContent used to be
|
||||
# DISCARDED whenever a note also had listContent, because a note could only be
|
||||
# one kind. A note holds both now, so nothing is dropped on the way in.
|
||||
kn = {
|
||||
"title": "Groceries",
|
||||
"textContent": "for the weekend",
|
||||
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
|
||||
"labels": [{"name": "shopping"}],
|
||||
"color": "TEAL",
|
||||
@@ -330,7 +334,7 @@ def test_keep_spec_list_note():
|
||||
"userEditedTimestampUsec": 1600000100000000,
|
||||
}
|
||||
spec = _keep_spec(kn, "Takeout/Keep")
|
||||
assert spec["kind"] == "list"
|
||||
assert spec["body"] == "for the weekend"
|
||||
assert spec["color"] == "teal"
|
||||
assert spec["pinned"] is True
|
||||
assert spec["archived"] is False
|
||||
@@ -348,7 +352,6 @@ def test_keep_spec_text_note_folds_annotation_urls_and_maps_color():
|
||||
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
|
||||
}
|
||||
spec = _keep_spec(kn, "Takeout/Keep")
|
||||
assert spec["kind"] == "text"
|
||||
assert "https://example.com" in spec["body"]
|
||||
assert spec["color"] == "orange"
|
||||
# attachment path is resolved relative to the note JSON's folder
|
||||
@@ -360,7 +363,6 @@ def test_native_spec_roundtrip_fields():
|
||||
"title": "T",
|
||||
"body": "b",
|
||||
"color": "blue",
|
||||
"kind": "text",
|
||||
"pinned": True,
|
||||
"archived": False,
|
||||
"created_at": "2026-07-19T00:00:00+00:00",
|
||||
|
||||
Reference in New Issue
Block a user