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
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:
@@ -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(
|
ComposeSheet(
|
||||||
saving = board.state.saving,
|
saving = board.state.saving,
|
||||||
onDismiss = { composing = false },
|
onDismiss = { composing = false },
|
||||||
onSave = { kind, title, content ->
|
onSave = { title, content ->
|
||||||
board.create(kind, title, content)
|
board.create(title, content)
|
||||||
composing = false
|
composing = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,9 +50,6 @@ sealed interface Destination {
|
|||||||
) : 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. */
|
/** Everything the board renders from, in one immutable snapshot. */
|
||||||
data class BoardState(
|
data class BoardState(
|
||||||
val destination: Destination = Destination.Notes,
|
val destination: Destination = Destination.Notes,
|
||||||
@@ -198,7 +195,6 @@ class BoardViewModel(
|
|||||||
* mistake worth interrupting someone over.
|
* mistake worth interrupting someone over.
|
||||||
*/
|
*/
|
||||||
fun create(
|
fun create(
|
||||||
kind: DraftKind,
|
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String,
|
||||||
) {
|
) {
|
||||||
@@ -210,7 +206,7 @@ class BoardViewModel(
|
|||||||
state = state.copy(saving = true)
|
state = state.copy(saving = true)
|
||||||
state =
|
state =
|
||||||
try {
|
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
|
// 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
|
// 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
|
// 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))
|
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
|
// 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
|
// you often pin while still reading — so unlike the three below, it
|
||||||
// deliberately leaves the editor open.
|
// deliberately leaves the editor open.
|
||||||
@@ -331,6 +324,10 @@ class BoardViewModel(
|
|||||||
null
|
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 ->
|
is EditorAction.AddItem ->
|
||||||
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
|
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
|
||||||
mutate { it.addItem(id, text) }
|
mutate { it.addItem(id, text) }
|
||||||
@@ -468,25 +465,11 @@ private fun query(
|
|||||||
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
|
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
|
||||||
|
|
||||||
private fun draft(
|
private fun draft(
|
||||||
kind: DraftKind,
|
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String,
|
||||||
): NoteDraft =
|
): NoteDraft =
|
||||||
when (kind) {
|
// Body carries the text; the core derives display_title from its first line when
|
||||||
// Body left to carry the text; the core derives display_title from its
|
// no title was given, so a captured thought is nameable without making the user
|
||||||
// first line when no title was given, so a captured thought is nameable
|
// name it. A checklist is added afterwards, in the editor — it is something a note
|
||||||
// without making the user name it.
|
// HAS, not a different thing to capture (M13 step 2).
|
||||||
DraftKind.NOTE ->
|
NoteDraft(title = title, body = content, color = DEFAULT_COLOR, items = null)
|
||||||
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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -58,18 +58,17 @@ import com.fabledsword.thoughtsync.R
|
|||||||
fun ComposeSheet(
|
fun ComposeSheet(
|
||||||
saving: Boolean,
|
saving: Boolean,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (DraftKind, String, String) -> Unit,
|
onSave: (String, String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
// Saveable, not just remembered: a rotation mid-sentence is the same lost
|
// Saveable, not just remembered: a rotation mid-sentence is the same lost
|
||||||
// thought as a discarded one, and it was losing it before this.
|
// 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 title by rememberSaveable { mutableStateOf("") }
|
||||||
var content by rememberSaveable { mutableStateOf("") }
|
var content by rememberSaveable { mutableStateOf("") }
|
||||||
val contentFocus = remember { FocusRequester() }
|
val contentFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
val written = title.isNotBlank() || content.isNotBlank()
|
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
|
// 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
|
// 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
|
// 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
|
// + 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.
|
// 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) {
|
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
|
||||||
Column(
|
Column(
|
||||||
@@ -91,19 +90,8 @@ fun ComposeSheet(
|
|||||||
.navigationBarsPadding(),
|
.navigationBarsPadding(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
// No note/list switch any more: there is one thing to capture. A
|
||||||
FilterChip(
|
// checklist is added to a note in the editor, once there is a note.
|
||||||
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)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
PlainTextField(
|
PlainTextField(
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = { title = it },
|
onValueChange = { title = it },
|
||||||
@@ -115,19 +103,14 @@ fun ComposeSheet(
|
|||||||
value = content,
|
value = content,
|
||||||
onValueChange = { content = it },
|
onValueChange = { content = it },
|
||||||
modifier = Modifier.focusRequester(contentFocus),
|
modifier = Modifier.focusRequester(contentFocus),
|
||||||
hint =
|
hint = R.string.compose_body_hint,
|
||||||
if (kind == DraftKind.LIST) {
|
|
||||||
R.string.compose_list_hint
|
|
||||||
} else {
|
|
||||||
R.string.compose_body_hint
|
|
||||||
},
|
|
||||||
minLines = MIN_CONTENT_LINES,
|
minLines = MIN_CONTENT_LINES,
|
||||||
)
|
)
|
||||||
|
|
||||||
SheetActions(
|
SheetActions(
|
||||||
canSave = !saving && written,
|
canSave = !saving && written,
|
||||||
onDiscard = onDismiss,
|
onDiscard = onDismiss,
|
||||||
onSave = { onSave(kind, title, content) },
|
onSave = { onSave(title, content) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ sealed interface EditorAction {
|
|||||||
) : EditorAction
|
) : EditorAction
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note ⇄ checklist.
|
* Give this note a checklist.
|
||||||
*
|
*
|
||||||
* Only `kind` changes: the body text and any existing items both stay where
|
* Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
|
||||||
* they are, so switching back and forth is lossless and a mis-tap costs
|
* so nothing moves and nothing is swapped: the body stays exactly where it is and
|
||||||
* nothing.
|
* the note gains a first, empty item for someone to type into.
|
||||||
*/
|
*/
|
||||||
data object ToggleKind : EditorAction
|
data object AddChecklist : EditorAction
|
||||||
|
|
||||||
data class SetPinned(
|
data class SetPinned(
|
||||||
val pinned: Boolean,
|
val pinned: Boolean,
|
||||||
|
|||||||
@@ -84,15 +84,16 @@ fun EditorBottomBar(
|
|||||||
contentDescription = stringResource(R.string.editor_reminder),
|
contentDescription = stringResource(R.string.editor_reminder),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onAction(EditorAction.ToggleKind) }) {
|
// Adds the first checklist item, which is what makes the checklist
|
||||||
val list = note.kind == KIND_LIST
|
// editor appear. Hidden once the note already has one — there is nothing
|
||||||
Icon(
|
// left to add that the checklist's own "+" row doesn't do better.
|
||||||
if (list) Icons.Filled.Create else Icons.AutoMirrored.Filled.List,
|
if (note.items.isEmpty()) {
|
||||||
contentDescription =
|
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
|
||||||
stringResource(
|
Icon(
|
||||||
if (list) R.string.editor_make_note else R.string.editor_make_list,
|
Icons.AutoMirrored.Filled.List,
|
||||||
),
|
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,8 @@ fun NoteCard(
|
|||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.kind == KIND_LIST) {
|
// Both, in order — a note can carry a body AND a checklist (M13 step 2).
|
||||||
Checklist(items = note.items)
|
if (note.body.isNotBlank()) {
|
||||||
} else if (note.body.isNotBlank()) {
|
|
||||||
Text(
|
Text(
|
||||||
text = note.body,
|
text = note.body,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -74,6 +73,10 @@ fun NoteCard(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
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
|
// A note with no title, no body and no items still has to occupy the
|
||||||
// board legibly — otherwise it reads as a rendering bug.
|
// board legibly — otherwise it reads as a rendering bug.
|
||||||
|
|||||||
@@ -150,16 +150,18 @@ fun NoteEditorScreen(
|
|||||||
bold = true,
|
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)
|
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()) {
|
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 -->
|
<!-- Compose sheet -->
|
||||||
<string name="compose_open">New note</string>
|
<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_title_hint">Title</string>
|
||||||
<string name="compose_body_hint">Take a note…</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_discard">Discard</string>
|
||||||
<string name="compose_save">Save</string>
|
<string name="compose_save">Save</string>
|
||||||
|
|
||||||
@@ -42,13 +39,12 @@
|
|||||||
<string name="board_open_note">Open note</string>
|
<string name="board_open_note">Open note</string>
|
||||||
<string name="editor_back">Back to notes</string>
|
<string name="editor_back">Back to notes</string>
|
||||||
<string name="editor_title_hint">Title</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_body_hint">Note</string>
|
||||||
<string name="editor_add_item">Add item</string>
|
<string name="editor_add_item">Add item</string>
|
||||||
<string name="editor_remove_item">Remove item</string>
|
<string name="editor_remove_item">Remove item</string>
|
||||||
<string name="editor_remove_label">Remove label</string>
|
<string name="editor_remove_label">Remove label</string>
|
||||||
<string name="editor_reminder">Set a reminder</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_more">More actions</string>
|
||||||
<string name="editor_pin">Pin</string>
|
<string name="editor_pin">Pin</string>
|
||||||
<string name="editor_unpin">Unpin</string>
|
<string name="editor_unpin">Unpin</string>
|
||||||
|
|||||||
@@ -573,7 +573,6 @@ mod tests {
|
|||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
color: "default".to_string(),
|
color: "default".to_string(),
|
||||||
kind: None,
|
|
||||||
items: None,
|
items: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -677,7 +676,6 @@ mod tests {
|
|||||||
title: "Packing".to_string(),
|
title: "Packing".to_string(),
|
||||||
body: String::new(),
|
body: String::new(),
|
||||||
color: "default".to_string(),
|
color: "default".to_string(),
|
||||||
kind: Some("list".to_string()),
|
|
||||||
items: Some(vec!["socks".to_string()]),
|
items: Some(vec!["socks".to_string()]),
|
||||||
})
|
})
|
||||||
.expect("create");
|
.expect("create");
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ pub struct Note {
|
|||||||
pub display_title: String,
|
pub display_title: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
pub kind: String,
|
|
||||||
pub position: i64,
|
pub position: i64,
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
pub archived: bool,
|
pub archived: bool,
|
||||||
@@ -134,7 +133,6 @@ impl From<core_models::Note> for Note {
|
|||||||
display_title,
|
display_title,
|
||||||
body,
|
body,
|
||||||
color,
|
color,
|
||||||
kind,
|
|
||||||
position,
|
position,
|
||||||
pinned,
|
pinned,
|
||||||
archived,
|
archived,
|
||||||
@@ -155,7 +153,6 @@ impl From<core_models::Note> for Note {
|
|||||||
display_title,
|
display_title,
|
||||||
body,
|
body,
|
||||||
color,
|
color,
|
||||||
kind,
|
|
||||||
position,
|
position,
|
||||||
pinned,
|
pinned,
|
||||||
archived,
|
archived,
|
||||||
@@ -294,7 +291,6 @@ pub struct NoteQuery {
|
|||||||
pub struct NoteFacets {
|
pub struct NoteFacets {
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub kind: Option<String>,
|
|
||||||
pub label: Option<Vec<String>>,
|
pub label: Option<Vec<String>>,
|
||||||
pub has_reminder: Option<bool>,
|
pub has_reminder: Option<bool>,
|
||||||
pub has_attachment: Option<bool>,
|
pub has_attachment: Option<bool>,
|
||||||
@@ -324,7 +320,6 @@ impl From<NoteFacets> for core_models::Facets {
|
|||||||
let NoteFacets {
|
let NoteFacets {
|
||||||
q,
|
q,
|
||||||
color,
|
color,
|
||||||
kind,
|
|
||||||
label,
|
label,
|
||||||
has_reminder,
|
has_reminder,
|
||||||
has_attachment,
|
has_attachment,
|
||||||
@@ -334,7 +329,6 @@ impl From<NoteFacets> for core_models::Facets {
|
|||||||
core_models::Facets {
|
core_models::Facets {
|
||||||
q,
|
q,
|
||||||
color,
|
color,
|
||||||
kind,
|
|
||||||
label,
|
label,
|
||||||
has_reminder,
|
has_reminder,
|
||||||
has_attachment,
|
has_attachment,
|
||||||
@@ -351,8 +345,8 @@ pub struct NoteDraft {
|
|||||||
pub body: String,
|
pub body: String,
|
||||||
/// "default" unless the user picked a colour.
|
/// "default" unless the user picked a colour.
|
||||||
pub color: String,
|
pub color: String,
|
||||||
pub kind: Option<String>,
|
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
|
||||||
/// Checklist lines, for `kind = "checklist"`.
|
/// is not an alternative to `body` — it is an addition to it.
|
||||||
pub items: Option<Vec<String>>,
|
pub items: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,14 +356,12 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
color,
|
color,
|
||||||
kind,
|
|
||||||
items,
|
items,
|
||||||
} = value;
|
} = value;
|
||||||
core_models::NoteCreateInput {
|
core_models::NoteCreateInput {
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
color,
|
color,
|
||||||
kind,
|
|
||||||
items,
|
items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,7 +381,6 @@ pub enum NoteEdit {
|
|||||||
ClearTitle,
|
ClearTitle,
|
||||||
Body { value: String },
|
Body { value: String },
|
||||||
Color { value: String },
|
Color { value: String },
|
||||||
Kind { value: String },
|
|
||||||
Pinned { value: bool },
|
Pinned { value: bool },
|
||||||
Archived { value: bool },
|
Archived { value: bool },
|
||||||
RemindAt { value: String },
|
RemindAt { value: String },
|
||||||
@@ -412,7 +403,6 @@ impl NoteEdit {
|
|||||||
NoteEdit::ClearTitle => ("title", Value::Null),
|
NoteEdit::ClearTitle => ("title", Value::Null),
|
||||||
NoteEdit::Body { value } => ("body", Value::String(value)),
|
NoteEdit::Body { value } => ("body", Value::String(value)),
|
||||||
NoteEdit::Color { value } => ("color", 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::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||||
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
||||||
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ pub struct Note {
|
|||||||
pub display_title: String,
|
pub display_title: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
pub kind: String,
|
|
||||||
pub position: i64,
|
pub position: i64,
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
pub archived: bool,
|
pub archived: bool,
|
||||||
@@ -136,8 +135,6 @@ pub struct NoteCreateInput {
|
|||||||
#[serde(default = "default_color")]
|
#[serde(default = "default_color")]
|
||||||
pub color: String,
|
pub color: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub kind: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub items: Option<Vec<String>>,
|
pub items: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,8 +159,6 @@ pub struct Facets {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub kind: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub label: Option<Vec<String>>,
|
pub label: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_reminder: Option<bool>,
|
pub has_reminder: Option<bool>,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ CREATE TABLE notes (
|
|||||||
title TEXT,
|
title TEXT,
|
||||||
body TEXT NOT NULL DEFAULT '',
|
body TEXT NOT NULL DEFAULT '',
|
||||||
color TEXT NOT NULL DEFAULT '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,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
archived 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.
|
/// Bring the database up to the latest schema. Idempotent.
|
||||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
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(SCHEMA_V5)?;
|
||||||
conn.execute_batch("PRAGMA user_version = 5;")?;
|
conn.execute_batch("PRAGMA user_version = 5;")?;
|
||||||
}
|
}
|
||||||
|
if version < 6 {
|
||||||
|
conn.execute_batch(SCHEMA_V6)?;
|
||||||
|
conn.execute_batch("PRAGMA user_version = 6;")?;
|
||||||
|
}
|
||||||
Ok(())
|
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> {
|
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||||
let mut note = conn.query_row(
|
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",
|
FROM notes WHERE id = ?1",
|
||||||
[id],
|
[id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -152,20 +152,19 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
|||||||
display_title: dt,
|
display_title: dt,
|
||||||
body,
|
body,
|
||||||
color: r.get(3)?,
|
color: r.get(3)?,
|
||||||
kind: r.get(4)?,
|
position: r.get(4)?,
|
||||||
position: r.get(5)?,
|
pinned: r.get(5)?,
|
||||||
pinned: r.get(6)?,
|
archived: r.get(6)?,
|
||||||
archived: r.get(7)?,
|
trashed: r.get(7)?,
|
||||||
trashed: r.get(8)?,
|
deleted_at: r.get(12)?,
|
||||||
deleted_at: r.get(13)?,
|
remind_at: r.get(8)?,
|
||||||
remind_at: r.get(9)?,
|
recurrence: r.get(9)?,
|
||||||
recurrence: r.get(10)?,
|
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
previews: Vec::new(),
|
previews: Vec::new(),
|
||||||
created_at: r.get(11)?,
|
created_at: r.get(10)?,
|
||||||
updated_at: r.get(12)?,
|
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 = ?");
|
sql.push_str(" AND color = ?");
|
||||||
binds.push(c.to_string());
|
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) {
|
if f.has_reminder == Some(true) {
|
||||||
sql.push_str(" AND remind_at IS NOT NULL");
|
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 id = new_id();
|
||||||
let ts = now();
|
let ts = now();
|
||||||
let title = normalize_title(&input.title);
|
let title = normalize_title(&input.title);
|
||||||
let kind = input.kind.clone().unwrap_or_else(|| "text".to_string());
|
|
||||||
let position: i64 = conn.query_row(
|
let position: i64 = conn.query_row(
|
||||||
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
|
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
|
||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty)
|
"INSERT INTO notes (id, title, body, color, position, created_at, updated_at, dirty)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, 1)",
|
||||||
params![id, title, input.body, input.color, kind, position, ts],
|
params![id, title, input.body, input.color, position, ts],
|
||||||
)?;
|
)?;
|
||||||
if let Some(items) = &input.items {
|
if let Some(items) = &input.items {
|
||||||
for (i, text) in items.iter().enumerate() {
|
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])?;
|
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" => {
|
"pinned" => {
|
||||||
if let Some(b) = v.as_bool() {
|
if let Some(b) = v.as_bool() {
|
||||||
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
|
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// The sync wire protocol this client speaks.
|
/// 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
|
/// The oldest server protocol this client can drive — the symmetric half of the
|
||||||
/// server's `min_client_protocol_version`.
|
/// 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
|
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
|
||||||
/// link rather than degrading it.
|
/// 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
|
// `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.
|
// never changes, and the server's copy is the same value anyway.
|
||||||
conn.execute(
|
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,
|
trashed, remind_at, recurrence, created_at, updated_at,
|
||||||
sync_revision, trashed_at, dirty)
|
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
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
body = excluded.body,
|
body = excluded.body,
|
||||||
color = excluded.color,
|
color = excluded.color,
|
||||||
kind = excluded.kind,
|
|
||||||
position = excluded.position,
|
position = excluded.position,
|
||||||
pinned = excluded.pinned,
|
pinned = excluded.pinned,
|
||||||
archived = excluded.archived,
|
archived = excluded.archived,
|
||||||
@@ -264,7 +263,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
|||||||
note.title,
|
note.title,
|
||||||
note.body,
|
note.body,
|
||||||
note.color,
|
note.color,
|
||||||
note.kind,
|
|
||||||
note.position,
|
note.position,
|
||||||
note.pinned,
|
note.pinned,
|
||||||
note.archived,
|
note.archived,
|
||||||
@@ -501,7 +499,6 @@ mod tests {
|
|||||||
title: Some("Title".into()),
|
title: Some("Title".into()),
|
||||||
body: "Body".into(),
|
body: "Body".into(),
|
||||||
color: "default".into(),
|
color: "default".into(),
|
||||||
kind: "text".into(),
|
|
||||||
position: 0,
|
position: 0,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
|
|||||||
+11
-17
@@ -69,7 +69,6 @@ pub struct Change {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub kind: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub pinned: Option<bool>,
|
pub pinned: Option<bool>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -102,7 +101,6 @@ impl Change {
|
|||||||
title: None,
|
title: None,
|
||||||
body: None,
|
body: None,
|
||||||
color: None,
|
color: None,
|
||||||
kind: None,
|
|
||||||
pinned: None,
|
pinned: None,
|
||||||
archived: None,
|
archived: None,
|
||||||
trashed: None,
|
trashed: None,
|
||||||
@@ -202,7 +200,6 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
|||||||
edited_at: r.get(3)?,
|
edited_at: r.get(3)?,
|
||||||
title: None,
|
title: None,
|
||||||
body: None,
|
body: None,
|
||||||
kind: None,
|
|
||||||
pinned: None,
|
pinned: None,
|
||||||
archived: None,
|
archived: None,
|
||||||
trashed: None,
|
trashed: None,
|
||||||
@@ -240,7 +237,6 @@ struct NoteRow {
|
|||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
body: String,
|
body: String,
|
||||||
color: String,
|
color: String,
|
||||||
kind: String,
|
|
||||||
position: i64,
|
position: i64,
|
||||||
pinned: bool,
|
pinned: bool,
|
||||||
archived: bool,
|
archived: bool,
|
||||||
@@ -253,7 +249,7 @@ struct NoteRow {
|
|||||||
|
|
||||||
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||||
conn.query_row(
|
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
|
remind_at, recurrence, created_at, updated_at
|
||||||
FROM notes WHERE id = ?1",
|
FROM notes WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
@@ -262,15 +258,14 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
|||||||
title: r.get(0)?,
|
title: r.get(0)?,
|
||||||
body: r.get(1)?,
|
body: r.get(1)?,
|
||||||
color: r.get(2)?,
|
color: r.get(2)?,
|
||||||
kind: r.get(3)?,
|
position: r.get(3)?,
|
||||||
position: r.get(4)?,
|
pinned: r.get::<_, i64>(4)? != 0,
|
||||||
pinned: r.get::<_, i64>(5)? != 0,
|
archived: r.get::<_, i64>(5)? != 0,
|
||||||
archived: r.get::<_, i64>(6)? != 0,
|
trashed: r.get::<_, i64>(6)? != 0,
|
||||||
trashed: r.get::<_, i64>(7)? != 0,
|
remind_at: r.get(7)?,
|
||||||
remind_at: r.get(8)?,
|
recurrence: r.get(8)?,
|
||||||
recurrence: r.get(9)?,
|
created_at: r.get(9)?,
|
||||||
created_at: r.get(10)?,
|
updated_at: r.get(10)?,
|
||||||
updated_at: r.get(11)?,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -312,7 +307,6 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
|||||||
title: row.title,
|
title: row.title,
|
||||||
body: Some(row.body),
|
body: Some(row.body),
|
||||||
color: Some(row.color),
|
color: Some(row.color),
|
||||||
kind: Some(row.kind),
|
|
||||||
pinned: Some(row.pinned),
|
pinned: Some(row.pinned),
|
||||||
archived: Some(row.archived),
|
archived: Some(row.archived),
|
||||||
trashed: Some(row.trashed),
|
trashed: Some(row.trashed),
|
||||||
@@ -535,9 +529,9 @@ mod tests {
|
|||||||
|
|
||||||
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
|
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
|
||||||
conn.execute(
|
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)
|
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)",
|
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
|
||||||
params![id, dirty],
|
params![id, dirty],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ pub struct Note {
|
|||||||
pub body: String,
|
pub body: String,
|
||||||
#[serde(default = "default_color")]
|
#[serde(default = "default_color")]
|
||||||
pub color: String,
|
pub color: String,
|
||||||
#[serde(default = "default_kind")]
|
|
||||||
pub kind: String,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub position: i64,
|
pub position: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -157,10 +155,6 @@ fn default_color() -> String {
|
|||||||
"default".to_string()
|
"default".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_kind() -> String {
|
|
||||||
"text".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_mime() -> String {
|
fn default_mime() -> String {
|
||||||
"application/octet-stream".to_string()
|
"application/octet-stream".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// stays in the stores — the repo is data access only.
|
// stays in the stores — the repo is data access only.
|
||||||
|
|
||||||
import type { NoteColor } from "../notes/colors";
|
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 { Label } from "../stores/labels";
|
||||||
import type { SavedFilter } from "../stores/savedFilters";
|
import type { SavedFilter } from "../stores/savedFilters";
|
||||||
import type { Device } from "../stores/devices";
|
import type { Device } from "../stores/devices";
|
||||||
@@ -35,13 +35,12 @@ export interface NoteCreateInput {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
kind?: NoteKind;
|
|
||||||
items?: string[];
|
items?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mutable subset of a note (PATCH /api/notes/:id).
|
// The mutable subset of a note (PATCH /api/notes/:id).
|
||||||
export type NoteChanges = Partial<
|
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 {
|
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);
|
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?.q) params.set("q", q.facets.q);
|
||||||
if (q.facets?.color) params.set("color", q.facets.color);
|
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_reminder) params.set("has_reminder", "true");
|
||||||
if (q.facets?.has_attachment) params.set("has_attachment", "true");
|
if (q.facets?.has_attachment) params.set("has_attachment", "true");
|
||||||
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
|
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";
|
import Icon from "./Icon.vue";
|
||||||
|
|
||||||
// A dead-simple facet bar over the board: text search + color + labels + has-reminder
|
// 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.
|
// filtered board is a shareable lens and a saved view is just a link.
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -35,9 +35,6 @@ function clearAll() {
|
|||||||
function setColor(c: NoteColor) {
|
function setColor(c: NoteColor) {
|
||||||
patch({ color: facets.value.color === c ? undefined : c });
|
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) {
|
function toggleLabel(id: string) {
|
||||||
const cur = facets.value.label ?? [];
|
const cur = facets.value.label ?? [];
|
||||||
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
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">
|
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
|
||||||
Has attachment
|
Has attachment
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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" />
|
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Checklist notes can't nest interactive controls in a <button>, so use a
|
<!-- One render path: every note is a body plus, maybe, checkable items.
|
||||||
focusable div; text notes keep a semantic button. -->
|
A focusable div rather than a <button>, because a checklist nests interactive
|
||||||
<template v-if="note.kind === 'list'">
|
controls and those cannot live inside a button — and the card is the same
|
||||||
<div
|
shape whether or not it happens to carry items today. -->
|
||||||
role="button"
|
<div
|
||||||
tabindex="0"
|
role="button"
|
||||||
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
tabindex="0"
|
||||||
@click="emit('open', note)"
|
class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||||
@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"
|
|
||||||
@click="emit('open', note)"
|
@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">
|
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{{ note.title }}
|
{{ 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">
|
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<MarkdownText :text="note.body" />
|
<MarkdownText :text="note.body" />
|
||||||
</div>
|
</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
|
Empty note
|
||||||
</p>
|
</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">
|
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ const title = ref(props.note?.title ?? "");
|
|||||||
const body = ref(props.note?.body ?? props.initialBody);
|
const body = ref(props.note?.body ?? props.initialBody);
|
||||||
const color = ref<NoteColor>(props.note?.color ?? "default");
|
const color = ref<NoteColor>(props.note?.color ?? "default");
|
||||||
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
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 saving = ref(false);
|
||||||
const root = ref<HTMLElement | null>(null);
|
const root = ref<HTMLElement | null>(null);
|
||||||
const bodyInput = ref<HTMLTextAreaElement | 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);
|
const richEnabled = computed(() => !isCreate.value || hasContent.value);
|
||||||
|
|
||||||
// A synthetic note for compose mode (before anything is persisted), so the shared
|
// 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>(() => ({
|
const draftNote = computed<Note>(() => ({
|
||||||
id: "",
|
id: "",
|
||||||
title: title.value.trim() || null,
|
title: title.value.trim() || null,
|
||||||
display_title: "",
|
display_title: "",
|
||||||
body: body.value,
|
body: body.value,
|
||||||
color: color.value,
|
color: color.value,
|
||||||
kind: createKind.value,
|
|
||||||
position: 0,
|
position: 0,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
@@ -78,13 +80,16 @@ const liveNote = computed<Note>(() =>
|
|||||||
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
|
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
|
||||||
: draftNote.value,
|
: draftNote.value,
|
||||||
);
|
);
|
||||||
// Only edit-mode list notes render the interactive checklist; compose-list types
|
// The checklist renders once the note has items, or once someone has asked for one.
|
||||||
// lines into the textarea (they become items on create).
|
// It sits BELOW the body rather than instead of it — a note can carry both, which is
|
||||||
const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list");
|
// the whole point of the merge.
|
||||||
const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list"));
|
//
|
||||||
const bodyPlaceholder = computed(() =>
|
// Items need a persisted note to hang off, so this is a rich action like attaching a
|
||||||
isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)",
|
// 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).
|
// Keep local state in sync when the edited note changes (modal reused for another note).
|
||||||
watch(
|
watch(
|
||||||
@@ -101,17 +106,7 @@ watch(
|
|||||||
|
|
||||||
// ---- persistence ----
|
// ---- persistence ----
|
||||||
async function createFromFields(): Promise<void> {
|
async function createFromFields(): Promise<void> {
|
||||||
let created: Note;
|
const created = await notes.create({ title: title.value, body: body.value, color: color.value });
|
||||||
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 });
|
|
||||||
}
|
|
||||||
noteId.value = created.id;
|
noteId.value = created.id;
|
||||||
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
|
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
|
||||||
}
|
}
|
||||||
@@ -139,7 +134,7 @@ async function flush(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const b = baseline.value;
|
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;
|
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
|
||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
@@ -157,7 +152,7 @@ function resetCompose(): void {
|
|||||||
body.value = "";
|
body.value = "";
|
||||||
color.value = "default";
|
color.value = "default";
|
||||||
labelList.value = [];
|
labelList.value = [];
|
||||||
createKind.value = "text";
|
checklistOpen.value = false;
|
||||||
baseline.value = { title: null, body: "", color: "default" };
|
baseline.value = { title: null, body: "", color: "default" };
|
||||||
uploadError.value = "";
|
uploadError.value = "";
|
||||||
}
|
}
|
||||||
@@ -301,29 +296,16 @@ function labelChip(c: string): string {
|
|||||||
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- kind toggle: compose = local flag, edit = convert the existing note ----
|
// ---- add a checklist ----
|
||||||
async function toggleKind() {
|
//
|
||||||
if (isCreate.value) {
|
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
|
||||||
createKind.value = createKind.value === "list" ? "text" : "list";
|
// body and gains a place to put items. Persists the draft first for the same reason
|
||||||
bodyInput.value?.focus();
|
// attaching a file does — an item needs a note to belong to.
|
||||||
return;
|
async function addChecklist() {
|
||||||
}
|
if (checklistOpen.value) return;
|
||||||
const id = noteId.value as string;
|
const id = await ensureDraft();
|
||||||
if (liveNote.value.kind === "list") {
|
if (!id) return;
|
||||||
await notes.setKind(id, "text");
|
checklistOpen.value = true;
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- attachments ----
|
// ---- attachments ----
|
||||||
@@ -563,7 +545,6 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
v-if="!showChecklist"
|
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
rows="8"
|
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"
|
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||||
@keydown="onBodyKeydown"
|
@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">
|
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
||||||
<span
|
<span
|
||||||
@@ -681,13 +669,12 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
</button>
|
</button>
|
||||||
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
|
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
|
||||||
<button
|
<button
|
||||||
v-if="!liveNote.trashed"
|
v-if="richEnabled && !liveNote.trashed && !showChecklist"
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''"
|
title="Add a checklist"
|
||||||
:title="isListMode ? 'Switch to a note' : 'Make a checklist'"
|
aria-label="Add a checklist"
|
||||||
:aria-pressed="isListMode"
|
@click="addChecklist"
|
||||||
@click="toggleKind"
|
|
||||||
>
|
>
|
||||||
<Icon name="checkbox" />
|
<Icon name="checkbox" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
|
|||||||
if (text) f.q = text;
|
if (text) f.q = text;
|
||||||
const color = one(q.color);
|
const color = one(q.color);
|
||||||
if (color) f.color = 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 (labels.length) f.label = labels;
|
||||||
if (one(q.has_reminder) === "true") f.has_reminder = true;
|
if (one(q.has_reminder) === "true") f.has_reminder = true;
|
||||||
if (one(q.has_attachment) === "true") f.has_attachment = true;
|
if (one(q.has_attachment) === "true") f.has_attachment = true;
|
||||||
@@ -33,7 +31,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
|
|||||||
const q: LocationQueryRaw = {};
|
const q: LocationQueryRaw = {};
|
||||||
if (f.q) q.q = f.q;
|
if (f.q) q.q = f.q;
|
||||||
if (f.color) q.color = f.color;
|
if (f.color) q.color = f.color;
|
||||||
if (f.kind) q.kind = f.kind;
|
|
||||||
if (f.label?.length) q.label = f.label;
|
if (f.label?.length) q.label = f.label;
|
||||||
if (f.has_reminder) q.has_reminder = "true";
|
if (f.has_reminder) q.has_reminder = "true";
|
||||||
if (f.has_attachment) q.has_attachment = "true";
|
if (f.has_attachment) q.has_attachment = "true";
|
||||||
@@ -47,7 +44,6 @@ export function facetCount(f: NoteFacets): number {
|
|||||||
let n = 0;
|
let n = 0;
|
||||||
if (f.q) n++;
|
if (f.q) n++;
|
||||||
if (f.color) n++;
|
if (f.color) n++;
|
||||||
if (f.kind) n++;
|
|
||||||
n += f.label?.length ?? 0;
|
n += f.label?.length ?? 0;
|
||||||
if (f.has_reminder) n++;
|
if (f.has_reminder) n++;
|
||||||
if (f.has_attachment) n++;
|
if (f.has_attachment) n++;
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ import { useUiStore } from "./ui";
|
|||||||
import type { NoteColor } from "../notes/colors";
|
import type { NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
export type NoteView = "active" | "archived" | "trash";
|
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
|
// 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.
|
// view's stored params). All optional; empty = the plain, unfiltered board.
|
||||||
export interface NoteFacets {
|
export interface NoteFacets {
|
||||||
q?: string;
|
q?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
kind?: NoteKind;
|
|
||||||
label?: string[];
|
label?: string[];
|
||||||
has_reminder?: boolean;
|
has_reminder?: boolean;
|
||||||
has_attachment?: boolean;
|
has_attachment?: boolean;
|
||||||
@@ -71,7 +68,6 @@ export interface Note {
|
|||||||
display_title: string;
|
display_title: string;
|
||||||
body: string;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
kind: NoteKind;
|
|
||||||
position: number;
|
position: number;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
@@ -141,8 +137,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
kind?: NoteKind;
|
items?: string[];
|
||||||
items?: string[];
|
|
||||||
}): Promise<Note> {
|
}): Promise<Note> {
|
||||||
const note = await repo.notes.create(input);
|
const note = await repo.notes.create(input);
|
||||||
reconcile(note);
|
reconcile(note);
|
||||||
@@ -152,7 +147,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
async function mutate(
|
async function mutate(
|
||||||
id: string,
|
id: string,
|
||||||
changes: Partial<
|
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> {
|
): Promise<void> {
|
||||||
reconcile(await repo.notes.update(id, changes));
|
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) });
|
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
|
||||||
};
|
};
|
||||||
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
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 setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
|
||||||
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
|
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
|
||||||
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
|
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
|
||||||
@@ -284,7 +278,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
setPinned,
|
setPinned,
|
||||||
setArchived,
|
setArchived,
|
||||||
setColor,
|
setColor,
|
||||||
setKind,
|
|
||||||
setReminder,
|
setReminder,
|
||||||
setRecurrence,
|
setRecurrence,
|
||||||
completeReminder,
|
completeReminder,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ class Note(Base):
|
|||||||
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||||
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
||||||
# 'text' (freeform body) or 'list' (a checklist of note_items).
|
# '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.
|
# Manual drag order (higher = earlier); 0 until the user reorders.
|
||||||
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
||||||
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||||
@@ -78,7 +77,6 @@ class Note(Base):
|
|||||||
"display_title": self.display_title,
|
"display_title": self.display_title,
|
||||||
"body": self.body,
|
"body": self.body,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
"kind": self.kind,
|
|
||||||
"position": self.position,
|
"position": self.position,
|
||||||
"pinned": self.pinned,
|
"pinned": self.pinned,
|
||||||
"archived": self.archived,
|
"archived": self.archived,
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from . import Base
|
|||||||
|
|
||||||
|
|
||||||
class NoteItem(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"
|
__tablename__ = "note_items"
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from . import Base
|
|||||||
class SavedFilter(Base):
|
class SavedFilter(Base):
|
||||||
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one
|
"""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
|
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"
|
__tablename__ = "saved_filters"
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ async def list_notes():
|
|||||||
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
||||||
label_params = request.args.getlist("label")
|
label_params = request.args.getlist("label")
|
||||||
color = request.args.get("color")
|
color = request.args.get("color")
|
||||||
kind = request.args.get("kind")
|
|
||||||
has_reminder = coerce_bool(request.args.get("has_reminder"))
|
has_reminder = coerce_bool(request.args.get("has_reminder"))
|
||||||
has_attachment = coerce_bool(request.args.get("has_attachment"))
|
has_attachment = coerce_bool(request.args.get("has_attachment"))
|
||||||
query_text = (request.args.get("q") or "").strip()
|
query_text = (request.args.get("q") or "").strip()
|
||||||
@@ -125,10 +124,6 @@ async def list_notes():
|
|||||||
if color not in NOTE_COLORS:
|
if color not in NOTE_COLORS:
|
||||||
return json_error("invalid color", 400)
|
return json_error("invalid color", 400)
|
||||||
stmt = stmt.where(Note.color == color)
|
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:
|
if has_reminder:
|
||||||
stmt = stmt.where(Note.remind_at.is_not(None))
|
stmt = stmt.where(Note.remind_at.is_not(None))
|
||||||
if has_attachment:
|
if has_attachment:
|
||||||
@@ -282,7 +277,6 @@ async def export_notes():
|
|||||||
"display_title": n.display_title,
|
"display_title": n.display_title,
|
||||||
"body": n.body,
|
"body": n.body,
|
||||||
"color": n.color,
|
"color": n.color,
|
||||||
"kind": n.kind,
|
|
||||||
"pinned": n.pinned,
|
"pinned": n.pinned,
|
||||||
"archived": n.archived,
|
"archived": n.archived,
|
||||||
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
|
"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 {}
|
data = await request.get_json(silent=True) or {}
|
||||||
title = data.get("title") if isinstance(data.get("title"), str) else ""
|
title = data.get("title") if isinstance(data.get("title"), str) else ""
|
||||||
body = data.get("body") if isinstance(data.get("body"), 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"
|
# Items are accepted on ANY note now — a checklist is something a note HAS.
|
||||||
# A checklist note's "content" is its items, not the body — so it's non-empty
|
item_texts = parse_list_items(data.get("items"))
|
||||||
# when it has a title or at least one item (quick-add can create one in one shot).
|
# "Empty" therefore means all three are empty, not just the two that used to
|
||||||
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
|
# matter for whichever kind this was.
|
||||||
if kind == "list":
|
if is_empty_note(title, body) and not item_texts:
|
||||||
if not (title.strip() or item_texts):
|
|
||||||
return json_error("note is empty", 400)
|
|
||||||
elif is_empty_note(title, body):
|
|
||||||
return json_error("note is empty", 400)
|
return json_error("note is empty", 400)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
# New notes go to the top of the manual order.
|
# New notes go to the top of the manual order.
|
||||||
@@ -440,7 +431,6 @@ async def create_note():
|
|||||||
title=clean_title,
|
title=clean_title,
|
||||||
display_title=derive_display_title(clean_title, body),
|
display_title=derive_display_title(clean_title, body),
|
||||||
body=body,
|
body=body,
|
||||||
kind=kind,
|
|
||||||
color=normalize_color(data.get("color")),
|
color=normalize_color(data.get("color")),
|
||||||
position=int(max_pos) + 1,
|
position=int(max_pos) + 1,
|
||||||
)
|
)
|
||||||
@@ -486,8 +476,6 @@ async def update_note(note_id: str):
|
|||||||
note.body = data["body"]
|
note.body = data["body"]
|
||||||
if "color" in data:
|
if "color" in data:
|
||||||
note.color = normalize_color(data["color"])
|
note.color = normalize_color(data["color"])
|
||||||
if "kind" in data and data["kind"] in ("text", "list"):
|
|
||||||
note.kind = data["kind"]
|
|
||||||
if "pinned" in data:
|
if "pinned" in data:
|
||||||
note.pinned = bool(data["pinned"])
|
note.pinned = bool(data["pinned"])
|
||||||
if "archived" in data:
|
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(f"updated: {note.updated_at.isoformat() if note.updated_at else ''}")
|
||||||
fm.append("---")
|
fm.append("---")
|
||||||
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:
|
for it in items:
|
||||||
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
|
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
|
||||||
else:
|
|
||||||
fm.append(note.body)
|
|
||||||
return "\n".join(fm) + "\n"
|
return "\n".join(fm) + "\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +104,6 @@ def _native_spec(n: dict) -> dict:
|
|||||||
return {
|
return {
|
||||||
"title": n.get("title"),
|
"title": n.get("title"),
|
||||||
"body": n.get("body") or "",
|
"body": n.get("body") or "",
|
||||||
"kind": n.get("kind"),
|
|
||||||
"color": n.get("color"),
|
"color": n.get("color"),
|
||||||
"pinned": bool(n.get("pinned")),
|
"pinned": bool(n.get("pinned")),
|
||||||
"archived": bool(n.get("archived")),
|
"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
|
"""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."""
|
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 []
|
list_content = kn.get("listContent") if isinstance(kn.get("listContent"), list) else []
|
||||||
is_list = bool(list_content)
|
# Keep's own notes are one or the other, but its text was being DISCARDED whenever
|
||||||
body = kn.get("textContent") or "" if not is_list else ""
|
# 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 —
|
# Keep stores link annotations (e.g. shared URLs) separately from the text —
|
||||||
# fold any URLs into the body so the content survives the move.
|
# fold any URLs into the body so the content survives the move.
|
||||||
urls = [
|
urls = [
|
||||||
@@ -155,7 +160,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
|
|||||||
return {
|
return {
|
||||||
"title": kn.get("title"),
|
"title": kn.get("title"),
|
||||||
"body": body,
|
"body": body,
|
||||||
"kind": "list" if is_list else "text",
|
|
||||||
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
|
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
|
||||||
"pinned": bool(kn.get("isPinned")),
|
"pinned": bool(kn.get("isPinned")),
|
||||||
"archived": bool(kn.get("isArchived")),
|
"archived": bool(kn.get("isArchived")),
|
||||||
@@ -276,12 +280,9 @@ async def _create_imported_note(
|
|||||||
(nothing written) when the spec is empty."""
|
(nothing written) when the spec is empty."""
|
||||||
title = (spec.get("title") or "").strip() or None
|
title = (spec.get("title") or "").strip() or None
|
||||||
body = spec.get("body") or ""
|
body = spec.get("body") or ""
|
||||||
kind = spec.get("kind") if spec.get("kind") in ("text", "list") else "text"
|
|
||||||
items = spec.get("items") or []
|
items = spec.get("items") or []
|
||||||
if kind == "list":
|
has_items = any((it.get("text") or "").strip() for it in items)
|
||||||
if not (title or any((it.get("text") or "").strip() for it in items)):
|
if is_empty_note(title, body) and not has_items:
|
||||||
return False
|
|
||||||
elif is_empty_note(title, body):
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
note = Note(
|
note = Note(
|
||||||
@@ -289,7 +290,6 @@ async def _create_imported_note(
|
|||||||
title=title,
|
title=title,
|
||||||
display_title=derive_display_title(title, body),
|
display_title=derive_display_title(title, body),
|
||||||
body=body,
|
body=body,
|
||||||
kind=kind,
|
|
||||||
color=normalize_color(spec.get("color")),
|
color=normalize_color(spec.get("color")),
|
||||||
pinned=bool(spec.get("pinned")),
|
pinned=bool(spec.get("pinned")),
|
||||||
archived=bool(spec.get("archived")),
|
archived=bool(spec.get("archived")),
|
||||||
@@ -310,11 +310,10 @@ async def _create_imported_note(
|
|||||||
db.add(note)
|
db.add(note)
|
||||||
await db.flush() # assign note.id before items/labels/attachments/links
|
await db.flush() # assign note.id before items/labels/attachments/links
|
||||||
|
|
||||||
if kind == "list":
|
for pos, it in enumerate(items):
|
||||||
for pos, it in enumerate(items):
|
text = (it.get("text") or "").strip()
|
||||||
text = (it.get("text") or "").strip()
|
if text:
|
||||||
if text:
|
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
|
||||||
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
|
# 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.
|
# body are handled by _reconcile_tags below, same as a normal create.
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ NAME_CAP = 100
|
|||||||
_ALLOWED_PARAM_KEYS = {
|
_ALLOWED_PARAM_KEYS = {
|
||||||
"q",
|
"q",
|
||||||
"color",
|
"color",
|
||||||
"kind",
|
|
||||||
"label", # matches the repeatable ?label= query param (stored as an array)
|
"label", # matches the repeatable ?label= query param (stored as an array)
|
||||||
"has_reminder",
|
"has_reminder",
|
||||||
"has_attachment",
|
"has_attachment",
|
||||||
|
|||||||
@@ -55,8 +55,15 @@ MAX_PUSH = 1000 # per-batch change cap
|
|||||||
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise
|
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise
|
||||||
# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch
|
# 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.
|
# that hard-blocks older clients, so additive changes must leave it alone.
|
||||||
SYNC_PROTOCOL_VERSION = 1
|
# v2 (M13): `kind` left the wire. Dropping a field a v1 client sends and expects back
|
||||||
MIN_CLIENT_PROTOCOL_VERSION = 1
|
# 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
|
# 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
|
# 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.title = (title or "").strip() or None if isinstance(title, str) else None
|
||||||
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
|
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
|
||||||
note.color = normalize_color(ch.get("color"))
|
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.pinned = bool(ch.get("pinned"))
|
||||||
note.archived = bool(ch.get("archived"))
|
note.archived = bool(ch.get("archived"))
|
||||||
if ch.get("trashed"):
|
if ch.get("trashed"):
|
||||||
|
|||||||
+6
-4
@@ -317,9 +317,13 @@ def test_usec_to_dt():
|
|||||||
assert _usec_to_dt(None) is None
|
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 = {
|
kn = {
|
||||||
"title": "Groceries",
|
"title": "Groceries",
|
||||||
|
"textContent": "for the weekend",
|
||||||
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
|
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
|
||||||
"labels": [{"name": "shopping"}],
|
"labels": [{"name": "shopping"}],
|
||||||
"color": "TEAL",
|
"color": "TEAL",
|
||||||
@@ -330,7 +334,7 @@ def test_keep_spec_list_note():
|
|||||||
"userEditedTimestampUsec": 1600000100000000,
|
"userEditedTimestampUsec": 1600000100000000,
|
||||||
}
|
}
|
||||||
spec = _keep_spec(kn, "Takeout/Keep")
|
spec = _keep_spec(kn, "Takeout/Keep")
|
||||||
assert spec["kind"] == "list"
|
assert spec["body"] == "for the weekend"
|
||||||
assert spec["color"] == "teal"
|
assert spec["color"] == "teal"
|
||||||
assert spec["pinned"] is True
|
assert spec["pinned"] is True
|
||||||
assert spec["archived"] is False
|
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"}],
|
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
|
||||||
}
|
}
|
||||||
spec = _keep_spec(kn, "Takeout/Keep")
|
spec = _keep_spec(kn, "Takeout/Keep")
|
||||||
assert spec["kind"] == "text"
|
|
||||||
assert "https://example.com" in spec["body"]
|
assert "https://example.com" in spec["body"]
|
||||||
assert spec["color"] == "orange"
|
assert spec["color"] == "orange"
|
||||||
# attachment path is resolved relative to the note JSON's folder
|
# attachment path is resolved relative to the note JSON's folder
|
||||||
@@ -360,7 +363,6 @@ def test_native_spec_roundtrip_fields():
|
|||||||
"title": "T",
|
"title": "T",
|
||||||
"body": "b",
|
"body": "b",
|
||||||
"color": "blue",
|
"color": "blue",
|
||||||
"kind": "text",
|
|
||||||
"pinned": True,
|
"pinned": True,
|
||||||
"archived": False,
|
"archived": False,
|
||||||
"created_at": "2026-07-19T00:00:00+00:00",
|
"created_at": "2026-07-19T00:00:00+00:00",
|
||||||
|
|||||||
Reference in New Issue
Block a user