Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME stays — search results, export filenames and the command palette all need one — but nothing is typed into it any more. `display_title` is now the first non-empty line of the body, falling back to the first checklist item. That fallback is what step 2 bought, and the reason this could not go first: a checklist had no body to be named from, so the title was its only name. Now every note has a body, and a note that is only a checklist is named by its first item. Gone everywhere: the column and note_revisions.title (0026), the field on the core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7), `normalize_title`, the wire field, the FFI record and `NoteEdit::Title` / `ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and the Android title field in both the compose sheet and the editor. **The search vector had to be rebuilt, not just left alone.** `notes.search_vector` is a STORED GENERATED column whose expression names `title` — Postgres refuses to drop a column another generated column depends on. It is dropped and recreated over `display_title` at weight A, which keeps the original intent: a note's NAME ranks above the rest of its body. **An imported title becomes the note's first body line.** Keep notes carry one, and so does any ThoughtSync export taken before this. Dropping it would silently lose text someone wrote; folding it in puts it exactly where a name now lives, so the note arrives named as it was. Skipped when the body already opens with that line, so re-importing an export this code produced doesn't stack duplicates. Two smaller things fell out. The Android editor loses its bold first field — one weight throughout, because the first line is the note's name but not a different KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s justification comment moved to `ClearRemindAt`, which is now the surviving example of why NoteEdit is a list rather than a struct of options. Protocol note corrected to say what actually shipped: v2 is "no kind, no title", one bump for the pair. Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests all green before pushing. It caught four things — orphaned serde attributes where fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview still has one), nine retention fixtures inserting a dropped column, and four rustfmt diffs.
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
"""drop notes.title and note_revisions.title — a note's name is its first line
|
||||||
|
|
||||||
|
Revision ID: 0026
|
||||||
|
Revises: 0025
|
||||||
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
|
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
|
||||||
|
non-empty line of that body, falling back to its first checklist item. There is no
|
||||||
|
separate field to type into, and `display_title` (already persisted, already what
|
||||||
|
search results and export filenames read) carries the name.
|
||||||
|
|
||||||
|
## The search vector has to be rebuilt, not just left alone
|
||||||
|
|
||||||
|
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
|
||||||
|
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
|
||||||
|
column depends on, and even if it didn't, the weighting would be wrong. So it is
|
||||||
|
dropped and recreated over `display_title` instead, which keeps the original
|
||||||
|
intent: the note's NAME ranks above the rest of its body.
|
||||||
|
|
||||||
|
Rebuilding a stored generated column re-computes every row, and the GIN index is
|
||||||
|
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
|
||||||
|
before running this against something large.
|
||||||
|
|
||||||
|
## What happens to existing titles
|
||||||
|
|
||||||
|
Nothing preserves them, deliberately: `display_title` was already derived from the
|
||||||
|
title when one was set, so every note keeps the NAME it had. What is lost is the
|
||||||
|
distinction between "this note has an explicit title" and "this note's first line is
|
||||||
|
its name" — which is the distinction being removed.
|
||||||
|
|
||||||
|
Imports are the exception and are handled in code, not here: a Keep note's title, or
|
||||||
|
one in an export taken before this, is folded in as the note's first body line rather
|
||||||
|
than dropped (see `_create_imported_note`).
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0026"
|
||||||
|
down_revision = "0025"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Order matters: the generated column depends on `title`, so it goes first.
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||||
|
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||||
|
|
||||||
|
op.drop_column("notes", "title")
|
||||||
|
op.drop_column("note_revisions", "title")
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
setweight(to_tsvector('english', coalesce(display_title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||||
|
) STORED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||||
|
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||||
|
|
||||||
|
# Comes back empty. The text is not gone — it is the first line of every body —
|
||||||
|
# but which notes once had an explicit title is not recorded anywhere.
|
||||||
|
op.add_column("notes", sa.Column("title", sa.Text(), nullable=True))
|
||||||
|
op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||||
|
) STORED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||||
@@ -226,8 +226,8 @@ private fun App(
|
|||||||
ComposeSheet(
|
ComposeSheet(
|
||||||
saving = board.state.saving,
|
saving = board.state.saving,
|
||||||
onDismiss = { composing = false },
|
onDismiss = { composing = false },
|
||||||
onSave = { title, content ->
|
onSave = { content ->
|
||||||
board.create(title, content)
|
board.create(content)
|
||||||
composing = false
|
composing = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -194,19 +194,15 @@ class BoardViewModel(
|
|||||||
* Blank input is ignored rather than rejected: an empty save is a slip, not a
|
* Blank input is ignored rather than rejected: an empty save is a slip, not a
|
||||||
* mistake worth interrupting someone over.
|
* mistake worth interrupting someone over.
|
||||||
*/
|
*/
|
||||||
fun create(
|
fun create(content: String) {
|
||||||
title: String,
|
|
||||||
content: String,
|
|
||||||
) {
|
|
||||||
val cleanTitle = title.trim()
|
|
||||||
val cleanContent = content.trim()
|
val cleanContent = content.trim()
|
||||||
if (cleanTitle.isEmpty() && cleanContent.isEmpty()) return
|
if (cleanContent.isEmpty()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
state = state.copy(saving = true)
|
state = state.copy(saving = true)
|
||||||
state =
|
state =
|
||||||
try {
|
try {
|
||||||
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanTitle, cleanContent)) }
|
val created = withContext(Dispatchers.IO) { core.createNote(draft(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
|
||||||
@@ -277,28 +273,10 @@ class BoardViewModel(
|
|||||||
EditorAction.Close -> state = state.copy(editing = null)
|
EditorAction.Close -> state = state.copy(editing = null)
|
||||||
EditorAction.DismissError -> dismissError()
|
EditorAction.DismissError -> dismissError()
|
||||||
|
|
||||||
// Text is the only edit that batches: title and body are typed
|
// Saved on close rather than per keystroke, so a session of typing
|
||||||
// together and saved together on close, so they cost one write and
|
// costs one write and one revision snapshot.
|
||||||
// one revision snapshot rather than two of each.
|
|
||||||
is EditorAction.SaveText ->
|
is EditorAction.SaveText ->
|
||||||
mutate {
|
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||||
it.updateNote(
|
|
||||||
id,
|
|
||||||
listOf(
|
|
||||||
// An emptied title CLEARS the column rather than
|
|
||||||
// storing "". The core derives `display_title` from
|
|
||||||
// the first body line when the title is null, so the
|
|
||||||
// difference is whether an untitled note is nameable
|
|
||||||
// or blank — exactly what `ClearTitle` exists for.
|
|
||||||
if (action.title.isBlank()) {
|
|
||||||
NoteEdit.ClearTitle
|
|
||||||
} else {
|
|
||||||
NoteEdit.Title(action.title.trim())
|
|
||||||
},
|
|
||||||
NoteEdit.Body(action.body),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
|
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
|
||||||
|
|
||||||
@@ -464,12 +442,8 @@ private fun query(
|
|||||||
labelId: String? = null,
|
labelId: String? = null,
|
||||||
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
|
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
|
||||||
|
|
||||||
private fun draft(
|
private fun draft(content: String): NoteDraft =
|
||||||
title: String,
|
// The core names the note from the body's first line, so a captured thought is
|
||||||
content: String,
|
// findable without anyone being asked to name it. A checklist is added afterwards,
|
||||||
): NoteDraft =
|
// in the editor — it is something a note HAS, not a different thing to capture.
|
||||||
// Body carries the text; the core derives display_title from its first line when
|
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
|
||||||
// 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)
|
|
||||||
|
|||||||
@@ -57,27 +57,26 @@ import com.fabledsword.thoughtsync.R
|
|||||||
fun ComposeSheet(
|
fun ComposeSheet(
|
||||||
saving: Boolean,
|
saving: Boolean,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (String, String) -> Unit,
|
onSave: (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 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 = content.isNotBlank()
|
||||||
val leave = { if (written) onSave(title, content) else onDismiss() }
|
val leave = { if (written) onSave(content) else onDismiss() }
|
||||||
|
|
||||||
// Land in the body, not the title. Most captures are a thought, not a titled
|
// Straight into the one field there is. A capture is a thought, and every field
|
||||||
// document, and making someone tab past an optional field is the difference
|
// someone has to tab past is the difference between "under a second" and not —
|
||||||
// between "under a second" and not.
|
// which is why the title field is gone rather than merely skipped (M13 step 3).
|
||||||
LaunchedEffect(Unit) { contentFocus.requestFocus() }
|
LaunchedEffect(Unit) { contentFocus.requestFocus() }
|
||||||
|
|
||||||
// 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(title, content) }
|
FlushOnStop { if (written) onSave(content) }
|
||||||
|
|
||||||
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
|
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
|
||||||
Column(
|
Column(
|
||||||
@@ -91,13 +90,6 @@ fun ComposeSheet(
|
|||||||
) {
|
) {
|
||||||
// No note/list switch any more: there is one thing to capture. A
|
// 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.
|
// checklist is added to a note in the editor, once there is a note.
|
||||||
PlainTextField(
|
|
||||||
value = title,
|
|
||||||
onValueChange = { title = it },
|
|
||||||
hint = R.string.compose_title_hint,
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
PlainTextField(
|
PlainTextField(
|
||||||
value = content,
|
value = content,
|
||||||
onValueChange = { content = it },
|
onValueChange = { content = it },
|
||||||
@@ -109,7 +101,7 @@ fun ComposeSheet(
|
|||||||
SheetActions(
|
SheetActions(
|
||||||
canSave = !saving && written,
|
canSave = !saving && written,
|
||||||
onDiscard = onDismiss,
|
onDiscard = onDismiss,
|
||||||
onSave = { onSave(title, content) },
|
onSave = { onSave(content) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ sealed interface EditorAction {
|
|||||||
data object DismissError : EditorAction
|
data object DismissError : EditorAction
|
||||||
|
|
||||||
data class SaveText(
|
data class SaveText(
|
||||||
val title: String,
|
|
||||||
val body: String,
|
val body: String,
|
||||||
) : EditorAction
|
) : EditorAction
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -50,21 +49,9 @@ fun NoteCard(
|
|||||||
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
) {
|
) {
|
||||||
// A title only renders when one was actually set. `displayTitle` is
|
// Body then checklist, in order — a note can carry both (M13 step 2), and
|
||||||
// derived from the first body line when it wasn't, so printing both would
|
// nothing above them: the first line of the body IS the note's name, at the
|
||||||
// show the same text twice.
|
// same weight as the rest of it (M13 steps 3 and 4).
|
||||||
note.title?.takeIf { it.isNotBlank() }?.let { title ->
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both, in order — a note can carry a body AND a checklist (M13 step 2).
|
|
||||||
if (note.body.isNotBlank()) {
|
if (note.body.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = note.body,
|
text = note.body,
|
||||||
@@ -78,9 +65,9 @@ fun NoteCard(
|
|||||||
Checklist(items = note.items)
|
Checklist(items = note.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A note with no title, no body and no items still has to occupy the
|
// A note with no body and no items still has to occupy the board legibly —
|
||||||
// board legibly — otherwise it reads as a rendering bug.
|
// otherwise it reads as a rendering bug.
|
||||||
if (note.title.isNullOrBlank() && note.body.isBlank() && note.items.isEmpty()) {
|
if (note.body.isBlank() && note.items.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.board_empty_note),
|
text = stringResource(R.string.board_empty_note),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.thoughtsync.R
|
||||||
import com.fabledsword.thoughtsync.core.Label
|
import com.fabledsword.thoughtsync.core.Label
|
||||||
@@ -62,7 +61,6 @@ fun NoteEditorScreen(
|
|||||||
|
|
||||||
// Keyed by note id: the editor is reused across notes, and without the key the
|
// Keyed by note id: the editor is reused across notes, and without the key the
|
||||||
// second note opened would show the first one's text.
|
// second note opened would show the first one's text.
|
||||||
var title by remember(note.id) { mutableStateOf(note.title.orEmpty()) }
|
|
||||||
var body by remember(note.id) { mutableStateOf(note.body) }
|
var body by remember(note.id) { mutableStateOf(note.body) }
|
||||||
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
||||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
||||||
@@ -77,8 +75,8 @@ fun NoteEditorScreen(
|
|||||||
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
||||||
// revision identical to the one before it.
|
// revision identical to the one before it.
|
||||||
val flush = {
|
val flush = {
|
||||||
if (!readOnly && (title != note.title.orEmpty() || body != note.body)) {
|
if (!readOnly && body != note.body) {
|
||||||
onAction(EditorAction.SaveText(title, body))
|
onAction(EditorAction.SaveText(body))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val leave = {
|
val leave = {
|
||||||
@@ -142,14 +140,9 @@ fun NoteEditorScreen(
|
|||||||
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
|
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
|
||||||
}
|
}
|
||||||
|
|
||||||
EditorField(
|
// One field. A note is its body; its NAME is that body's first line, so
|
||||||
value = title,
|
// there is nothing separate to type into and nothing to render bolder
|
||||||
onValueChange = { title = it },
|
// than the line beneath it (M13 steps 3 and 4).
|
||||||
hint = R.string.editor_title_hint,
|
|
||||||
enabled = !readOnly,
|
|
||||||
bold = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
EditorField(
|
EditorField(
|
||||||
value = body,
|
value = body,
|
||||||
onValueChange = { body = it },
|
onValueChange = { body = it },
|
||||||
@@ -252,11 +245,15 @@ private fun EditorOverlays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The title and body fields.
|
* The note's body field.
|
||||||
*
|
*
|
||||||
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
|
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
|
||||||
* the note's colour, and a filled field would draw a second surface over the first
|
* the note's colour, and a filled field would draw a second surface over the first
|
||||||
* and turn a note into a form.
|
* and turn a note into a form.
|
||||||
|
*
|
||||||
|
* One weight throughout. The first line is the note's name, but it is not a
|
||||||
|
* different KIND of text from the line after it, and typing it should not feel like
|
||||||
|
* filling in a header.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditorField(
|
private fun EditorField(
|
||||||
@@ -264,7 +261,6 @@ private fun EditorField(
|
|||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
@StringRes hint: Int,
|
@StringRes hint: Int,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
bold: Boolean = false,
|
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
) {
|
) {
|
||||||
PlainTextField(
|
PlainTextField(
|
||||||
@@ -272,16 +268,8 @@ private fun EditorField(
|
|||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
hint = hint,
|
hint = hint,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
// The title is one line by contract — it is a name, and a name that wraps
|
|
||||||
// has become a body. The body itself never is.
|
|
||||||
singleLine = bold,
|
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
textStyle =
|
textStyle = MaterialTheme.typography.bodyLarge,
|
||||||
if (bold) {
|
|
||||||
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.typography.bodyLarge
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
<!-- Compose sheet -->
|
<!-- Compose sheet -->
|
||||||
<string name="compose_open">New note</string>
|
<string name="compose_open">New note</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_discard">Discard</string>
|
<string name="compose_discard">Discard</string>
|
||||||
<string name="compose_save">Save</string>
|
<string name="compose_save">Save</string>
|
||||||
@@ -38,7 +37,6 @@
|
|||||||
<!-- Editor -->
|
<!-- Editor -->
|
||||||
<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_add_checklist">Add a checklist</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>
|
||||||
|
|||||||
+25
-43
@@ -568,9 +568,8 @@ mod tests {
|
|||||||
dir.to_string_lossy().into_owned()
|
dir.to_string_lossy().into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draft(title: &str, body: &str) -> NoteDraft {
|
fn draft(body: &str) -> NoteDraft {
|
||||||
NoteDraft {
|
NoteDraft {
|
||||||
title: title.to_string(),
|
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
color: "default".to_string(),
|
color: "default".to_string(),
|
||||||
items: None,
|
items: None,
|
||||||
@@ -587,62 +586,50 @@ mod tests {
|
|||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
let created = app
|
let created = app
|
||||||
.create_note(draft("Groceries", "milk"))
|
.create_note(draft("Groceries\nmilk"))
|
||||||
.expect("create should succeed");
|
.expect("create should succeed");
|
||||||
assert_eq!(created.title.as_deref(), Some("Groceries"));
|
assert_eq!(created.body, "Groceries\nmilk");
|
||||||
assert_eq!(created.body, "milk");
|
|
||||||
|
|
||||||
let fetched = app
|
let fetched = app
|
||||||
.get_note(created.id.clone())
|
.get_note(created.id.clone())
|
||||||
.expect("get should succeed");
|
.expect("get should succeed");
|
||||||
assert_eq!(fetched.id, created.id);
|
assert_eq!(fetched.id, created.id);
|
||||||
|
// The NAME is the first line — there is no title field to have set (M13 step 3).
|
||||||
assert_eq!(fetched.display_title, "Groceries");
|
assert_eq!(fetched.display_title, "Groceries");
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A body-only note still has to be nameable — that is what `display_title` is
|
/// Every note has to be nameable — that is what `display_title` is for, and the
|
||||||
/// for, and the Android board relies on it exactly as the desktop does.
|
/// Android board relies on it exactly as the desktop does.
|
||||||
#[test]
|
#[test]
|
||||||
fn body_only_notes_still_have_a_display_title() {
|
fn a_note_is_named_by_its_first_line() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
let created = app
|
let created = app
|
||||||
.create_note(draft("", "just a thought"))
|
.create_note(draft("just a thought"))
|
||||||
.expect("create should succeed");
|
.expect("create should succeed");
|
||||||
assert_eq!(created.title, None);
|
|
||||||
assert_eq!(created.display_title, "just a thought");
|
assert_eq!(created.display_title, "just a thought");
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clearing a field and setting one are different edits, and the difference has
|
/// The hole that made removing the title unsafe until checklists stopped being
|
||||||
/// to survive the trip through the patch object.
|
/// their own kind of thing: a note with no body text still needs a name.
|
||||||
#[test]
|
#[test]
|
||||||
fn edits_can_both_set_and_clear_a_title() {
|
fn a_note_with_only_items_is_named_by_its_first_item() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app.create_note(draft("First", "body")).expect("create");
|
|
||||||
|
|
||||||
let renamed = app
|
let created = app
|
||||||
.update_note(
|
.create_note(NoteDraft {
|
||||||
note.id.clone(),
|
body: String::new(),
|
||||||
vec![NoteEdit::Title {
|
color: "default".to_string(),
|
||||||
value: "Second".to_string(),
|
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
||||||
}],
|
})
|
||||||
)
|
.expect("create should succeed");
|
||||||
.expect("rename");
|
assert_eq!(created.display_title, "milk");
|
||||||
assert_eq!(renamed.title.as_deref(), Some("Second"));
|
|
||||||
|
|
||||||
let cleared = app
|
|
||||||
.update_note(note.id.clone(), vec![NoteEdit::ClearTitle])
|
|
||||||
.expect("clear");
|
|
||||||
assert_eq!(
|
|
||||||
cleared.title, None,
|
|
||||||
"ClearTitle must null the column, not set it to an empty string — the \
|
|
||||||
distinction is why NoteEdit is a list rather than a struct of options"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
@@ -673,8 +660,7 @@ mod tests {
|
|||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app
|
let note = app
|
||||||
.create_note(NoteDraft {
|
.create_note(NoteDraft {
|
||||||
title: "Packing".to_string(),
|
body: "Packing".to_string(),
|
||||||
body: String::new(),
|
|
||||||
color: "default".to_string(),
|
color: "default".to_string(),
|
||||||
items: Some(vec!["socks".to_string()]),
|
items: Some(vec!["socks".to_string()]),
|
||||||
})
|
})
|
||||||
@@ -728,7 +714,7 @@ mod tests {
|
|||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
let note = app
|
let note = app
|
||||||
.create_note(draft("Trip", "book the ferry #travel"))
|
.create_note(draft("Trip\nbook the ferry #travel"))
|
||||||
.expect("create");
|
.expect("create");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
note.labels.len(),
|
note.labels.len(),
|
||||||
@@ -767,7 +753,7 @@ mod tests {
|
|||||||
fn deleting_forever_removes_the_note() {
|
fn deleting_forever_removes_the_note() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app.create_note(draft("Ephemeral", "body")).expect("create");
|
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
||||||
|
|
||||||
app.delete_note_forever(note.id.clone())
|
app.delete_note_forever(note.id.clone())
|
||||||
.expect("delete forever");
|
.expect("delete forever");
|
||||||
@@ -784,7 +770,7 @@ mod tests {
|
|||||||
fn reminders_can_be_snoozed_and_completed() {
|
fn reminders_can_be_snoozed_and_completed() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app.create_note(draft("Call back", "")).expect("create");
|
let note = app.create_note(draft("Call back")).expect("create");
|
||||||
assert_eq!(note.remind_at, None);
|
assert_eq!(note.remind_at, None);
|
||||||
|
|
||||||
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
|
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
|
||||||
@@ -810,9 +796,7 @@ mod tests {
|
|||||||
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app
|
let note = app.create_note(draft("Water the plants")).expect("create");
|
||||||
.create_note(draft("Water the plants", ""))
|
|
||||||
.expect("create");
|
|
||||||
|
|
||||||
let armed = app
|
let armed = app
|
||||||
.update_note(
|
.update_note(
|
||||||
@@ -849,9 +833,7 @@ mod tests {
|
|||||||
|
|
||||||
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
|
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
|
||||||
// invisibly on a note with no reminder.
|
// invisibly on a note with no reminder.
|
||||||
let once = app
|
let once = app.create_note(draft("Post the letter")).expect("create");
|
||||||
.create_note(draft("Post the letter", ""))
|
|
||||||
.expect("create");
|
|
||||||
app.update_note(
|
app.update_note(
|
||||||
once.id.clone(),
|
once.id.clone(),
|
||||||
vec![NoteEdit::RemindAt {
|
vec![NoteEdit::RemindAt {
|
||||||
|
|||||||
+19
-37
@@ -29,9 +29,8 @@ use thoughtsync_core::sync::state as core_state;
|
|||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct Note {
|
pub struct Note {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: Option<String>,
|
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||||
/// Title if set, else the first body line — always present, so a body-only note
|
/// Always present. Derived by the core, never stored.
|
||||||
/// is still nameable. Derived by the core, never stored.
|
|
||||||
pub display_title: String,
|
pub display_title: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
@@ -129,7 +128,6 @@ impl From<core_models::Note> for Note {
|
|||||||
// Exhaustive on purpose — see the module header.
|
// Exhaustive on purpose — see the module header.
|
||||||
let core_models::Note {
|
let core_models::Note {
|
||||||
id,
|
id,
|
||||||
title,
|
|
||||||
display_title,
|
display_title,
|
||||||
body,
|
body,
|
||||||
color,
|
color,
|
||||||
@@ -149,7 +147,6 @@ impl From<core_models::Note> for Note {
|
|||||||
} = value;
|
} = value;
|
||||||
Note {
|
Note {
|
||||||
id,
|
id,
|
||||||
title,
|
|
||||||
display_title,
|
display_title,
|
||||||
body,
|
body,
|
||||||
color,
|
color,
|
||||||
@@ -341,7 +338,6 @@ impl From<NoteFacets> for core_models::Facets {
|
|||||||
/// A new note.
|
/// A new note.
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct NoteDraft {
|
pub struct NoteDraft {
|
||||||
pub title: String,
|
|
||||||
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,
|
||||||
@@ -352,18 +348,8 @@ pub struct NoteDraft {
|
|||||||
|
|
||||||
impl From<NoteDraft> for core_models::NoteCreateInput {
|
impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||||
fn from(value: NoteDraft) -> Self {
|
fn from(value: NoteDraft) -> Self {
|
||||||
let NoteDraft {
|
let NoteDraft { body, color, items } = value;
|
||||||
title,
|
core_models::NoteCreateInput { body, color, items }
|
||||||
body,
|
|
||||||
color,
|
|
||||||
items,
|
|
||||||
} = value;
|
|
||||||
core_models::NoteCreateInput {
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
color,
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,14 +357,12 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
|
|||||||
///
|
///
|
||||||
/// A LIST of these rather than a struct of optional fields, because the core's patch
|
/// A LIST of these rather than a struct of optional fields, because the core's patch
|
||||||
/// semantics distinguish three states — leave alone, set to a value, and clear to
|
/// semantics distinguish three states — leave alone, set to a value, and clear to
|
||||||
/// null — and Kotlin has no way to express the third with a nullable field. `title:
|
/// null — and Kotlin has no way to express the third with a nullable field.
|
||||||
/// null` in a data class is indistinguishable from `title` unset, so the editor
|
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
|
||||||
/// could never clear a title. Explicit `Clear*` variants say it out loud, and Kotlin
|
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
|
||||||
/// gets a sealed class it can `when` over exhaustively.
|
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
|
||||||
#[derive(Debug, Clone, uniffi::Enum)]
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
pub enum NoteEdit {
|
pub enum NoteEdit {
|
||||||
Title { value: String },
|
|
||||||
ClearTitle,
|
|
||||||
Body { value: String },
|
Body { value: String },
|
||||||
Color { value: String },
|
Color { value: String },
|
||||||
Pinned { value: bool },
|
Pinned { value: bool },
|
||||||
@@ -399,8 +383,6 @@ impl NoteEdit {
|
|||||||
fn entry(self) -> (&'static str, serde_json::Value) {
|
fn entry(self) -> (&'static str, serde_json::Value) {
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
match self {
|
match self {
|
||||||
NoteEdit::Title { value } => ("title", Value::String(value)),
|
|
||||||
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::Pinned { value } => ("pinned", Value::Bool(value)),
|
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||||
@@ -415,8 +397,8 @@ impl NoteEdit {
|
|||||||
|
|
||||||
/// Fold a list of edits into the single patch object the store applies.
|
/// Fold a list of edits into the single patch object the store applies.
|
||||||
///
|
///
|
||||||
/// Later edits win on a repeated key, which is what a caller batching "set title,
|
/// Later edits win on a repeated key, which is what a caller batching "set a
|
||||||
/// then clear title" would expect.
|
/// reminder, then clear it" would expect.
|
||||||
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
|
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
|
||||||
let mut map = serde_json::Map::new();
|
let mut map = serde_json::Map::new();
|
||||||
for edit in edits {
|
for edit in edits {
|
||||||
@@ -697,14 +679,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_set_and_a_clear_are_different_patch_entries() {
|
fn a_set_and_a_clear_are_different_patch_entries() {
|
||||||
let set = patch_from(vec![NoteEdit::Title {
|
let set = patch_from(vec![NoteEdit::RemindAt {
|
||||||
value: "x".to_string(),
|
value: "2026-01-01T00:00:00Z".to_string(),
|
||||||
}]);
|
}]);
|
||||||
assert_eq!(set["title"], serde_json::json!("x"));
|
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
let cleared = patch_from(vec![NoteEdit::ClearTitle]);
|
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
|
||||||
assert!(
|
assert!(
|
||||||
cleared["title"].is_null(),
|
cleared["remind_at"].is_null(),
|
||||||
"a clear must reach the store as JSON null — an absent key means \
|
"a clear must reach the store as JSON null — an absent key means \
|
||||||
'leave alone', which is a different instruction"
|
'leave alone', which is a different instruction"
|
||||||
);
|
);
|
||||||
@@ -720,11 +702,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn later_edits_win_on_a_repeated_field() {
|
fn later_edits_win_on_a_repeated_field() {
|
||||||
let patch = patch_from(vec![
|
let patch = patch_from(vec![
|
||||||
NoteEdit::Title {
|
NoteEdit::RemindAt {
|
||||||
value: "first".to_string(),
|
value: "2026-01-01T00:00:00Z".to_string(),
|
||||||
},
|
},
|
||||||
NoteEdit::ClearTitle,
|
NoteEdit::ClearRemindAt,
|
||||||
]);
|
]);
|
||||||
assert!(patch["title"].is_null());
|
assert!(patch["remind_at"].is_null());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct Note {
|
pub struct Note {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: Option<String>,
|
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||||
/// title if set, else the note's first body line — always present, so body-only
|
/// Always present, so every note has something to be called. Derived at read time,
|
||||||
/// notes still have something to be called. Derived, never stored.
|
/// never stored.
|
||||||
pub display_title: String,
|
pub display_title: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
@@ -71,7 +71,6 @@ pub struct LinkPreview {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct NoteRevision {
|
pub struct NoteRevision {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: Option<String>,
|
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -128,8 +127,6 @@ fn default_color() -> String {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct NoteCreateInput {
|
pub struct NoteCreateInput {
|
||||||
#[serde(default)]
|
|
||||||
pub title: String,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub body: String,
|
pub body: String,
|
||||||
#[serde(default = "default_color")]
|
#[serde(default = "default_color")]
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ mod tests {
|
|||||||
let when = Utc::now() - age;
|
let when = Utc::now() - age;
|
||||||
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||||
VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)",
|
VALUES (?1, 'B', ?2, ?2, 1, ?2)",
|
||||||
rusqlite::params![id, stamped],
|
rusqlite::params![id, stamped],
|
||||||
)
|
)
|
||||||
.expect("insert");
|
.expect("insert");
|
||||||
@@ -149,8 +149,8 @@ mod tests {
|
|||||||
fn an_untrashed_note_is_never_swept() {
|
fn an_untrashed_note_is_never_swept() {
|
||||||
let conn = db();
|
let conn = db();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed)
|
"INSERT INTO notes (id, body, created_at, updated_at, trashed)
|
||||||
VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
|
VALUES ('live', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.expect("insert");
|
.expect("insert");
|
||||||
@@ -163,8 +163,8 @@ mod tests {
|
|||||||
// "Age unknown" must never resolve to "delete it".
|
// "Age unknown" must never resolve to "delete it".
|
||||||
let conn = db();
|
let conn = db();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||||
VALUES ('weird', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
|
VALUES ('weird', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.expect("insert");
|
.expect("insert");
|
||||||
@@ -179,8 +179,8 @@ mod tests {
|
|||||||
let conn = db();
|
let conn = db();
|
||||||
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
|
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||||
VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)",
|
VALUES ('server', 'B', ?1, ?1, 1, ?1)",
|
||||||
rusqlite::params![stamped],
|
rusqlite::params![stamped],
|
||||||
)
|
)
|
||||||
.expect("insert");
|
.expect("insert");
|
||||||
|
|||||||
@@ -170,6 +170,16 @@ const SCHEMA_V6: &str = r#"
|
|||||||
ALTER TABLE notes DROP COLUMN kind;
|
ALTER TABLE notes DROP COLUMN kind;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and
|
||||||
|
// its NAME is the first non-empty line of that body, falling back to its first item —
|
||||||
|
// derived at read time, never stored (see store::display_title).
|
||||||
|
//
|
||||||
|
// note_revisions loses its copy for the same reason: a revision snapshots a body.
|
||||||
|
const SCHEMA_V7: &str = r#"
|
||||||
|
ALTER TABLE notes DROP COLUMN title;
|
||||||
|
ALTER TABLE note_revisions DROP COLUMN title;
|
||||||
|
"#;
|
||||||
|
|
||||||
/// 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;")?;
|
||||||
@@ -198,5 +208,9 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
conn.execute_batch(SCHEMA_V6)?;
|
conn.execute_batch(SCHEMA_V6)?;
|
||||||
conn.execute_batch("PRAGMA user_version = 6;")?;
|
conn.execute_batch("PRAGMA user_version = 6;")?;
|
||||||
}
|
}
|
||||||
|
if version < 7 {
|
||||||
|
conn.execute_batch(SCHEMA_V7)?;
|
||||||
|
conn.execute_batch("PRAGMA user_version = 7;")?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-74
@@ -24,30 +24,26 @@ fn new_id() -> String {
|
|||||||
Uuid::new_v4().to_string()
|
Uuid::new_v4().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// title if non-empty, else the first non-blank body line — always a string.
|
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||||
fn display_title(title: Option<&str>, body: &str) -> String {
|
///
|
||||||
if let Some(t) = title {
|
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
|
||||||
let t = t.trim();
|
/// twice, and they have to agree or a synced note is called different things on either
|
||||||
if !t.is_empty() {
|
/// side of the wire.
|
||||||
return t.to_string();
|
///
|
||||||
}
|
/// Pure, and given the items rather than fetching them: every caller has already
|
||||||
|
/// loaded them, so a query here would be a second trip for something already in hand.
|
||||||
|
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
|
||||||
|
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
|
||||||
|
return line.to_string();
|
||||||
}
|
}
|
||||||
body.lines()
|
items
|
||||||
.map(str::trim)
|
.iter()
|
||||||
.find(|l| !l.is_empty())
|
.map(|i| i.text.trim())
|
||||||
|
.find(|t| !t.is_empty())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_title(raw: &str) -> Option<String> {
|
|
||||||
let t = raw.trim();
|
|
||||||
if t.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(t.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_like(s: &str) -> String {
|
fn escape_like(s: &str) -> String {
|
||||||
s.replace('\\', "\\\\")
|
s.replace('\\', "\\\\")
|
||||||
.replace('%', "\\%")
|
.replace('%', "\\%")
|
||||||
@@ -139,32 +135,29 @@ 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, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
|
"SELECT id, 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| {
|
||||||
let title: Option<String> = r.get(1)?;
|
let body: String = r.get(1)?;
|
||||||
let body: String = r.get(2)?;
|
|
||||||
let dt = display_title(title.as_deref(), &body);
|
|
||||||
Ok(Note {
|
Ok(Note {
|
||||||
id: r.get(0)?,
|
id: r.get(0)?,
|
||||||
title,
|
display_title: String::new(), // filled below — it may need a query
|
||||||
display_title: dt,
|
|
||||||
body,
|
body,
|
||||||
color: r.get(3)?,
|
color: r.get(2)?,
|
||||||
position: r.get(4)?,
|
position: r.get(3)?,
|
||||||
pinned: r.get(5)?,
|
pinned: r.get(4)?,
|
||||||
archived: r.get(6)?,
|
archived: r.get(5)?,
|
||||||
trashed: r.get(7)?,
|
trashed: r.get(6)?,
|
||||||
deleted_at: r.get(12)?,
|
deleted_at: r.get(11)?,
|
||||||
remind_at: r.get(8)?,
|
remind_at: r.get(7)?,
|
||||||
recurrence: r.get(9)?,
|
recurrence: r.get(8)?,
|
||||||
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(10)?,
|
created_at: r.get(9)?,
|
||||||
updated_at: r.get(11)?,
|
updated_at: r.get(10)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@@ -172,6 +165,8 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
|||||||
note.items = load_items(conn, id)?;
|
note.items = load_items(conn, id)?;
|
||||||
note.attachments = load_attachments(conn, id)?;
|
note.attachments = load_attachments(conn, id)?;
|
||||||
note.previews = load_previews(conn, id)?;
|
note.previews = load_previews(conn, id)?;
|
||||||
|
// After the items, because a body-only-empty note is named by its first one.
|
||||||
|
note.display_title = display_title(¬e.body, ¬e.items);
|
||||||
Ok(note)
|
Ok(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +262,7 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
|||||||
|
|
||||||
if let Some(f) = &q.facets {
|
if let Some(f) = &q.facets {
|
||||||
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
||||||
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
|
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
||||||
let pat = format!("%{}%", escape_like(text));
|
let pat = format!("%{}%", escape_like(text));
|
||||||
binds.push(pat.clone());
|
binds.push(pat.clone());
|
||||||
binds.push(pat);
|
binds.push(pat);
|
||||||
@@ -321,23 +316,31 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
|
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
|
||||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
// Names come from `load_note` rather than from a bare row, because a note whose
|
||||||
let rows = stmt.query_map([], |r| {
|
// body is empty is named by its first checklist item — which a row here doesn't
|
||||||
let title: Option<String> = r.get(1)?;
|
// have. The command palette reads this; correctness beats one query per note at
|
||||||
let body: String = r.get(2)?;
|
// personal scale.
|
||||||
Ok(TitleEntry {
|
let ids: Vec<String> = {
|
||||||
id: r.get(0)?,
|
let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
|
||||||
title: display_title(title.as_deref(), &body),
|
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||||
|
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||||
|
};
|
||||||
|
ids.iter()
|
||||||
|
.map(|id| {
|
||||||
|
let note = load_note(conn, id)?;
|
||||||
|
Ok(TitleEntry {
|
||||||
|
id: note.id,
|
||||||
|
title: note.display_title,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})?;
|
.collect()
|
||||||
rows.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
||||||
let pat = format!("%{}%", escape_like(q));
|
let pat = format!("%{}%", escape_like(q));
|
||||||
let ids: Vec<String> = {
|
let ids: Vec<String> = {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id FROM notes WHERE trashed = 0 AND (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC",
|
"SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
|
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
|
||||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||||
@@ -350,16 +353,15 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
|||||||
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
|
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
|
||||||
let id = new_id();
|
let id = new_id();
|
||||||
let ts = now();
|
let ts = now();
|
||||||
let title = normalize_title(&input.title);
|
|
||||||
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, position, created_at, updated_at, dirty)
|
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, 1)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
|
||||||
params![id, title, input.body, input.color, position, ts],
|
params![id, 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() {
|
||||||
@@ -374,13 +376,11 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||||
let (title, body): (Option<String>, String) =
|
let body: String =
|
||||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
|
||||||
})?;
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![new_id(), id, title, body, now()],
|
params![new_id(), id, body, now()],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -391,20 +391,13 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
|
|||||||
.as_object()
|
.as_object()
|
||||||
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
||||||
|
|
||||||
// Snapshot the pre-edit title/body once if either is being changed (version history).
|
// Snapshot the pre-edit body before changing it (version history).
|
||||||
if obj.contains_key("title") || obj.contains_key("body") {
|
if obj.contains_key("body") {
|
||||||
snapshot_revision(conn, id)?;
|
snapshot_revision(conn, id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (k, v) in obj {
|
for (k, v) in obj {
|
||||||
match k.as_str() {
|
match k.as_str() {
|
||||||
"title" => {
|
|
||||||
let norm = v.as_str().and_then(normalize_title);
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE notes SET title = ?1 WHERE id = ?2",
|
|
||||||
params![norm, id],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
"body" => {
|
"body" => {
|
||||||
let body = v.as_str().unwrap_or("");
|
let body = v.as_str().unwrap_or("");
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -653,28 +646,27 @@ pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<(
|
|||||||
|
|
||||||
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
|
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
|
.prepare("SELECT id, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
|
||||||
let rows = stmt.query_map([id], |r| {
|
let rows = stmt.query_map([id], |r| {
|
||||||
Ok(NoteRevision {
|
Ok(NoteRevision {
|
||||||
id: r.get(0)?,
|
id: r.get(0)?,
|
||||||
title: r.get(1)?,
|
body: r.get(1)?,
|
||||||
body: r.get(2)?,
|
created_at: r.get(2)?,
|
||||||
created_at: r.get(3)?,
|
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
|
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
|
||||||
let (title, body): (Option<String>, String) = conn.query_row(
|
let body: String = conn.query_row(
|
||||||
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
|
"SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
|
||||||
params![rev_id, id],
|
params![rev_id, id],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
snapshot_revision(conn, id)?;
|
snapshot_revision(conn, id)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
|
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||||
params![title, body, id],
|
params![body, id],
|
||||||
)?;
|
)?;
|
||||||
sync_tags(conn, id, &body)?;
|
sync_tags(conn, id, &body)?;
|
||||||
touch(conn, id)?;
|
touch(conn, id)?;
|
||||||
|
|||||||
@@ -240,12 +240,11 @@ 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, position, pinned, archived,
|
"INSERT INTO notes (id, 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, 0)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
title = excluded.title,
|
|
||||||
body = excluded.body,
|
body = excluded.body,
|
||||||
color = excluded.color,
|
color = excluded.color,
|
||||||
position = excluded.position,
|
position = excluded.position,
|
||||||
@@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
|||||||
dirty = 0",
|
dirty = 0",
|
||||||
params![
|
params![
|
||||||
note.id,
|
note.id,
|
||||||
note.title,
|
|
||||||
note.body,
|
note.body,
|
||||||
note.color,
|
note.color,
|
||||||
note.position,
|
note.position,
|
||||||
@@ -496,7 +494,6 @@ mod tests {
|
|||||||
fn note(id: &str, revision: i64) -> wire::Note {
|
fn note(id: &str, revision: i64) -> wire::Note {
|
||||||
wire::Note {
|
wire::Note {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title: Some("Title".into()),
|
|
||||||
body: "Body".into(),
|
body: "Body".into(),
|
||||||
color: "default".into(),
|
color: "default".into(),
|
||||||
position: 0,
|
position: 0,
|
||||||
|
|||||||
+13
-20
@@ -62,8 +62,6 @@ pub struct Change {
|
|||||||
pub op: &'static str,
|
pub op: &'static str,
|
||||||
pub edited_at: String,
|
pub edited_at: String,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub body: Option<String>,
|
pub body: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -97,7 +95,6 @@ impl Change {
|
|||||||
id,
|
id,
|
||||||
op: "delete",
|
op: "delete",
|
||||||
edited_at,
|
edited_at,
|
||||||
title: None,
|
|
||||||
body: None,
|
body: None,
|
||||||
color: None,
|
color: None,
|
||||||
pinned: None,
|
pinned: None,
|
||||||
@@ -197,7 +194,6 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
|||||||
name: Some(r.get(1)?),
|
name: Some(r.get(1)?),
|
||||||
color: Some(r.get(2)?),
|
color: Some(r.get(2)?),
|
||||||
edited_at: r.get(3)?,
|
edited_at: r.get(3)?,
|
||||||
title: None,
|
|
||||||
body: None,
|
body: None,
|
||||||
pinned: None,
|
pinned: None,
|
||||||
archived: None,
|
archived: None,
|
||||||
@@ -233,7 +229,6 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
|
|||||||
/// The note's own columns. A named struct rather than a twelve-wide tuple so the
|
/// The note's own columns. A named struct rather than a twelve-wide tuple so the
|
||||||
/// field-to-column mapping stays readable at the call site.
|
/// field-to-column mapping stays readable at the call site.
|
||||||
struct NoteRow {
|
struct NoteRow {
|
||||||
title: Option<String>,
|
|
||||||
body: String,
|
body: String,
|
||||||
color: String,
|
color: String,
|
||||||
position: i64,
|
position: i64,
|
||||||
@@ -248,23 +243,22 @@ 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, position, pinned, archived, trashed,
|
"SELECT 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],
|
||||||
|r| {
|
|r| {
|
||||||
Ok(NoteRow {
|
Ok(NoteRow {
|
||||||
title: r.get(0)?,
|
body: r.get(0)?,
|
||||||
body: r.get(1)?,
|
color: r.get(1)?,
|
||||||
color: r.get(2)?,
|
position: r.get(2)?,
|
||||||
position: r.get(3)?,
|
pinned: r.get::<_, i64>(3)? != 0,
|
||||||
pinned: r.get::<_, i64>(4)? != 0,
|
archived: r.get::<_, i64>(4)? != 0,
|
||||||
archived: r.get::<_, i64>(5)? != 0,
|
trashed: r.get::<_, i64>(5)? != 0,
|
||||||
trashed: r.get::<_, i64>(6)? != 0,
|
remind_at: r.get(6)?,
|
||||||
remind_at: r.get(7)?,
|
recurrence: r.get(7)?,
|
||||||
recurrence: r.get(8)?,
|
created_at: r.get(8)?,
|
||||||
created_at: r.get(9)?,
|
updated_at: r.get(9)?,
|
||||||
updated_at: r.get(10)?,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -303,7 +297,6 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
|||||||
// The local `updated_at` IS the client's edit time, which is what the
|
// The local `updated_at` IS the client's edit time, which is what the
|
||||||
// server's last-write-wins comparison runs against.
|
// server's last-write-wins comparison runs against.
|
||||||
edited_at: row.updated_at,
|
edited_at: row.updated_at,
|
||||||
title: row.title,
|
|
||||||
body: Some(row.body),
|
body: Some(row.body),
|
||||||
color: Some(row.color),
|
color: Some(row.color),
|
||||||
pinned: Some(row.pinned),
|
pinned: Some(row.pinned),
|
||||||
@@ -528,9 +521,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, position, pinned, archived,
|
"INSERT INTO notes (id, 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', 0, 0, 0, 0,
|
VALUES (?1, '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],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ pub struct ChangesPage {
|
|||||||
pub struct Note {
|
pub struct Note {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub title: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub body: String,
|
pub body: String,
|
||||||
#[serde(default = "default_color")]
|
#[serde(default = "default_color")]
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export interface NoteListQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteCreateInput {
|
export interface NoteCreateInput {
|
||||||
title: string;
|
|
||||||
body: string;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
items?: string[];
|
items?: string[];
|
||||||
@@ -40,7 +39,7 @@ export interface NoteCreateInput {
|
|||||||
|
|
||||||
// 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" | "pinned" | "archived" | "remind_at" | "recurrence">
|
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface ChecklistItemChanges {
|
export interface ChecklistItemChanges {
|
||||||
|
|||||||
@@ -221,14 +221,11 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
@click="emit('open', note)"
|
@click="emit('open', note)"
|
||||||
@keydown.enter="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 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
|
<p
|
||||||
v-if="!note.title && !note.body && !note.items.length && !note.attachments.length"
|
v-if="!note.body && !note.items.length && !note.attachments.length"
|
||||||
class="text-sm italic text-neutral-400"
|
class="text-sm italic text-neutral-400"
|
||||||
>
|
>
|
||||||
Empty note
|
Empty note
|
||||||
@@ -236,7 +233,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
</div>
|
</div>
|
||||||
<NoteChecklist
|
<NoteChecklist
|
||||||
v-if="note.items.length"
|
v-if="note.items.length"
|
||||||
:class="note.body || note.title ? 'mt-2' : ''"
|
:class="note.body ? 'mt-2' : ''"
|
||||||
:note-id="note.id"
|
:note-id="note.id"
|
||||||
:items="note.items"
|
:items="note.items"
|
||||||
@click="emit('open', note)"
|
@click="emit('open', note)"
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const notes = useNotesStore();
|
|||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
|
|
||||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||||
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] : []);
|
||||||
@@ -42,14 +41,13 @@ const fileInput = ref<HTMLInputElement | null>(null);
|
|||||||
const uploadError = ref("");
|
const uploadError = ref("");
|
||||||
|
|
||||||
// Baseline for edit-mode change detection (save only when text actually changed).
|
// Baseline for edit-mode change detection (save only when text actually changed).
|
||||||
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
|
const baseline = ref<{ body: string; color: NoteColor }>({
|
||||||
title: props.note?.title ?? null,
|
|
||||||
body: props.note?.body ?? "",
|
body: props.note?.body ?? "",
|
||||||
color: (props.note?.color ?? "default") as NoteColor,
|
color: (props.note?.color ?? "default") as NoteColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCreate = computed(() => noteId.value === null);
|
const isCreate = computed(() => noteId.value === null);
|
||||||
const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() !== "");
|
const hasContent = computed(() => body.value.trim() !== "");
|
||||||
// Rich features need a saved note; in compose they light up once there's content.
|
// Rich features need a saved note; in compose they light up once there's content.
|
||||||
const richEnabled = computed(() => !isCreate.value || hasContent.value);
|
const richEnabled = computed(() => !isCreate.value || hasContent.value);
|
||||||
|
|
||||||
@@ -57,7 +55,6 @@ const richEnabled = computed(() => !isCreate.value || hasContent.value);
|
|||||||
// template can read attachments/items/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,
|
|
||||||
display_title: "",
|
display_title: "",
|
||||||
body: body.value,
|
body: body.value,
|
||||||
color: color.value,
|
color: color.value,
|
||||||
@@ -96,19 +93,18 @@ watch(
|
|||||||
() => props.note,
|
() => props.note,
|
||||||
(n) => {
|
(n) => {
|
||||||
noteId.value = n?.id ?? null;
|
noteId.value = n?.id ?? null;
|
||||||
title.value = n?.title ?? "";
|
|
||||||
body.value = n?.body ?? "";
|
body.value = n?.body ?? "";
|
||||||
color.value = (n?.color ?? "default") as NoteColor;
|
color.value = (n?.color ?? "default") as NoteColor;
|
||||||
labelList.value = n ? [...n.labels] : [];
|
labelList.value = n ? [...n.labels] : [];
|
||||||
baseline.value = { title: n?.title ?? null, body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- persistence ----
|
// ---- persistence ----
|
||||||
async function createFromFields(): Promise<void> {
|
async function createFromFields(): Promise<void> {
|
||||||
const created = await notes.create({ title: title.value, body: body.value, color: color.value });
|
const created = await notes.create({ 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 = { body: created.body, color: created.color as NoteColor };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
|
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
|
||||||
@@ -135,12 +131,12 @@ async function flush(): Promise<void> {
|
|||||||
}
|
}
|
||||||
const b = baseline.value;
|
const b = baseline.value;
|
||||||
const nextBody = body.value;
|
const nextBody = body.value;
|
||||||
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
|
const changed = nextBody !== b.body || color.value !== b.color;
|
||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value });
|
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
|
||||||
baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value };
|
baseline.value = { body: nextBody, color: color.value };
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@@ -148,12 +144,11 @@ async function flush(): Promise<void> {
|
|||||||
|
|
||||||
function resetCompose(): void {
|
function resetCompose(): void {
|
||||||
noteId.value = null;
|
noteId.value = null;
|
||||||
title.value = "";
|
|
||||||
body.value = "";
|
body.value = "";
|
||||||
color.value = "default";
|
color.value = "default";
|
||||||
labelList.value = [];
|
labelList.value = [];
|
||||||
checklistOpen.value = false;
|
checklistOpen.value = false;
|
||||||
baseline.value = { title: null, body: "", color: "default" };
|
baseline.value = { body: "", color: "default" };
|
||||||
uploadError.value = "";
|
uploadError.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,12 +244,6 @@ function onBodyKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTitleEnter(e: KeyboardEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.shiftKey && isCreate.value) void commitAndContinue();
|
|
||||||
else bodyInput.value?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- reminder ----
|
// ---- reminder ----
|
||||||
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
||||||
async function onReminderChange(e: Event) {
|
async function onReminderChange(e: Event) {
|
||||||
@@ -413,10 +402,9 @@ async function restoreRevisionAt(revId: string) {
|
|||||||
const id = noteId.value;
|
const id = noteId.value;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const updated = await notes.restoreRevision(id, revId);
|
const updated = await notes.restoreRevision(id, revId);
|
||||||
title.value = updated.title ?? "";
|
|
||||||
body.value = updated.body;
|
body.value = updated.body;
|
||||||
color.value = updated.color;
|
color.value = updated.color;
|
||||||
baseline.value = { title: updated.title, body: updated.body, color: updated.color };
|
baseline.value = { body: updated.body, color: updated.color };
|
||||||
void loadRevisions(); // the pre-restore state became a new revision
|
void loadRevisions(); // the pre-restore state became a new revision
|
||||||
}
|
}
|
||||||
function revLabel(iso: string | null): string {
|
function revLabel(iso: string | null): string {
|
||||||
@@ -424,9 +412,7 @@ function revLabel(iso: string | null): string {
|
|||||||
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
function revPreview(rev: NoteRevision): string {
|
function revPreview(rev: NoteRevision): string {
|
||||||
const t = (rev.title ?? "").trim();
|
const s = rev.body.trim().replace(/\s+/g, " ");
|
||||||
const b = rev.body.trim().replace(/\s+/g, " ");
|
|
||||||
const s = t && b ? `${t} — ${b}` : t || b;
|
|
||||||
if (!s) return "(empty)";
|
if (!s) return "(empty)";
|
||||||
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
|
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
|
||||||
}
|
}
|
||||||
@@ -536,14 +522,6 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
|
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
|
||||||
|
|
||||||
<input
|
|
||||||
v-model="title"
|
|
||||||
type="text"
|
|
||||||
placeholder="Title (optional)"
|
|
||||||
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
|
||||||
@keydown.enter="onTitleEnter"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
|
|||||||
@@ -52,19 +52,17 @@ export interface LinkPreview {
|
|||||||
site_name: string | null;
|
site_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A past version of a note's title+body (version history).
|
// A past version of a note's body (version history).
|
||||||
export interface NoteRevision {
|
export interface NoteRevision {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
|
||||||
body: string;
|
body: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
// The note's NAME: its first body line, else its first checklist item
|
||||||
// The note's display NAME: explicit title, else its first body line (server-derived).
|
// (server-derived). Every note has one, so every note has something to be called.
|
||||||
// Every note has one, so a body-only note still has something to be called.
|
|
||||||
display_title: string;
|
display_title: string;
|
||||||
body: string;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
@@ -134,10 +132,9 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function create(input: {
|
async function create(input: {
|
||||||
title: string;
|
|
||||||
body: string;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
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);
|
||||||
@@ -147,7 +144,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" | "pinned" | "archived" | "remind_at" | "recurrence">
|
Pick<Note, "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));
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ class Note(Base):
|
|||||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
# The note's NAME: its first non-empty body line, else its first checklist item
|
||||||
# The note's display NAME: explicit title if set, else the first non-empty body
|
# (see notes.derive_display_title). There is no title field to prefer — a note is
|
||||||
# line (see notes.derive_display_title). Persisted so every note — even a body-only
|
# a body plus optional items, and this is simply the first thing written in it.
|
||||||
# one — has something to be called in search results and in an export filename,
|
# Persisted so search results and export filenames have something to say, and so
|
||||||
# without forcing the user to type a title.
|
# the full-text vector can weight it above the rest of the body.
|
||||||
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||||
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")
|
||||||
@@ -73,7 +73,6 @@ class Note(Base):
|
|||||||
def serialize(self) -> dict:
|
def serialize(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(self.id),
|
"id": str(self.id),
|
||||||
"title": self.title,
|
|
||||||
"display_title": self.display_title,
|
"display_title": self.display_title,
|
||||||
"body": self.body,
|
"body": self.body,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ from . import Base
|
|||||||
|
|
||||||
|
|
||||||
class NoteRevision(Base):
|
class NoteRevision(Base):
|
||||||
"""A point-in-time snapshot of a note's title+body, written on each edit that
|
"""A point-in-time snapshot of a note's body, written on each edit that changes
|
||||||
changes either — so an accidental overwrite can be viewed and restored. Only
|
it — so an accidental overwrite can be viewed and restored. Only the body is
|
||||||
title+body are versioned in v1 (not items/attachments/labels)."""
|
versioned (not items/attachments/labels)."""
|
||||||
|
|
||||||
__tablename__ = "note_revisions"
|
__tablename__ = "note_revisions"
|
||||||
__table_args__ = (Index("ix_note_revisions_note_created", "note_id", "created_at"),)
|
__table_args__ = (Index("ix_note_revisions_note_created", "note_id", "created_at"),)
|
||||||
@@ -22,6 +22,5 @@ class NoteRevision(Base):
|
|||||||
note_id: Mapped[uuid.UUID] = mapped_column(
|
note_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
|
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
|
||||||
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|||||||
@@ -139,8 +139,9 @@ async def list_notes():
|
|||||||
return json_error("invalid created_before", 400)
|
return json_error("invalid created_before", 400)
|
||||||
stmt = stmt.where(Note.created_at < before_dt)
|
stmt = stmt.where(Note.created_at < before_dt)
|
||||||
if query_text:
|
if query_text:
|
||||||
# Full-text match over title+body (generated tsvector, migration 0005),
|
# Full-text match over the note's name + body (generated tsvector,
|
||||||
# ranked — so the facet bar's text box searches, not just filters.
|
# migrations 0005/0026), ranked — so the facet bar's text box searches,
|
||||||
|
# not just filters.
|
||||||
tsquery = func.websearch_to_tsquery("english", query_text)
|
tsquery = func.websearch_to_tsquery("english", query_text)
|
||||||
search_col = literal_column("notes.search_vector")
|
search_col = literal_column("notes.search_vector")
|
||||||
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
|
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
|
||||||
@@ -273,7 +274,6 @@ async def export_notes():
|
|||||||
payload["notes"].append(
|
payload["notes"].append(
|
||||||
{
|
{
|
||||||
"id": str(n.id),
|
"id": str(n.id),
|
||||||
"title": n.title,
|
|
||||||
"display_title": n.display_title,
|
"display_title": n.display_title,
|
||||||
"body": n.body,
|
"body": n.body,
|
||||||
"color": n.color,
|
"color": n.color,
|
||||||
@@ -406,17 +406,32 @@ async def reorder_notes():
|
|||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
async def _name_for(db, note: Note, item_texts: list[str] | None = None) -> str:
|
||||||
|
"""The note's display name, consulting its checklist only when the body is silent.
|
||||||
|
|
||||||
|
`item_texts` short-circuits the query for callers that already hold the items
|
||||||
|
(create, import). Everyone else pays one narrow SELECT, and only when the body
|
||||||
|
produced nothing — which is the uncommon case.
|
||||||
|
"""
|
||||||
|
name = derive_display_title(note.body)
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
if item_texts is not None:
|
||||||
|
return derive_display_title("", item_texts[0] if item_texts else None)
|
||||||
|
first = await db.scalar(
|
||||||
|
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
|
||||||
|
)
|
||||||
|
return derive_display_title("", first)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("")
|
@bp.post("")
|
||||||
@login_required
|
@login_required
|
||||||
async def create_note():
|
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 ""
|
|
||||||
body = data.get("body") if isinstance(data.get("body"), str) else ""
|
body = data.get("body") if isinstance(data.get("body"), str) else ""
|
||||||
# Items are accepted on ANY note now — a checklist is something a note HAS.
|
# Items are accepted on ANY note — a checklist is something a note HAS.
|
||||||
item_texts = parse_list_items(data.get("items"))
|
item_texts = parse_list_items(data.get("items"))
|
||||||
# "Empty" therefore means all three are empty, not just the two that used to
|
if is_empty_note(body, item_texts):
|
||||||
# matter for whichever kind this was.
|
|
||||||
if is_empty_note(title, body) and not item_texts:
|
|
||||||
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.
|
||||||
@@ -425,11 +440,9 @@ async def create_note():
|
|||||||
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
|
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
clean_title = title.strip() or None
|
|
||||||
note = Note(
|
note = Note(
|
||||||
owner_id=g.user_id,
|
owner_id=g.user_id,
|
||||||
title=clean_title,
|
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
|
||||||
display_title=derive_display_title(clean_title, body),
|
|
||||||
body=body,
|
body=body,
|
||||||
color=normalize_color(data.get("color")),
|
color=normalize_color(data.get("color")),
|
||||||
position=int(max_pos) + 1,
|
position=int(max_pos) + 1,
|
||||||
@@ -467,11 +480,7 @@ async def update_note(note_id: str):
|
|||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return not_found()
|
return not_found()
|
||||||
old_title = note.title
|
|
||||||
old_body = note.body
|
old_body = note.body
|
||||||
if "title" in data:
|
|
||||||
title = data["title"] if isinstance(data["title"], str) else ""
|
|
||||||
note.title = title.strip() or None
|
|
||||||
if "body" in data and isinstance(data["body"], str):
|
if "body" in data and isinstance(data["body"], str):
|
||||||
note.body = data["body"]
|
note.body = data["body"]
|
||||||
if "color" in data:
|
if "color" in data:
|
||||||
@@ -492,15 +501,12 @@ async def update_note(note_id: str):
|
|||||||
note.remind_at = remind_dt
|
note.remind_at = remind_dt
|
||||||
if "recurrence" in data:
|
if "recurrence" in data:
|
||||||
note.recurrence = normalize_recurrence(data["recurrence"])
|
note.recurrence = normalize_recurrence(data["recurrence"])
|
||||||
# Recompute the display name (explicit title, else first body line) whenever
|
|
||||||
# the title or body may have changed.
|
|
||||||
if "title" in data or "body" in data:
|
|
||||||
note.display_title = derive_display_title(note.title, note.body)
|
|
||||||
if "body" in data:
|
if "body" in data:
|
||||||
|
note.display_title = await _name_for(db, note)
|
||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
# Version history: snapshot the PRE-edit title+body whenever either changed.
|
# Version history: snapshot the PRE-edit body whenever it changed.
|
||||||
if note.title != old_title or note.body != old_body:
|
if note.body != old_body:
|
||||||
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
db.add(NoteRevision(note_id=note.id, body=old_body))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
@@ -509,7 +515,6 @@ async def update_note(note_id: str):
|
|||||||
def _serialize_revision(rev: NoteRevision) -> dict:
|
def _serialize_revision(rev: NoteRevision) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(rev.id),
|
"id": str(rev.id),
|
||||||
"title": rev.title,
|
|
||||||
"body": rev.body,
|
"body": rev.body,
|
||||||
"created_at": iso(rev.created_at),
|
"created_at": iso(rev.created_at),
|
||||||
}
|
}
|
||||||
@@ -546,14 +551,13 @@ async def restore_revision(note_id: str, rev_id: str):
|
|||||||
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
|
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
|
||||||
if rev is None:
|
if rev is None:
|
||||||
return not_found()
|
return not_found()
|
||||||
if note.title == rev.title and note.body == rev.body:
|
if note.body == rev.body:
|
||||||
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
||||||
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
||||||
# the revision — with the same title/body ripple as a normal edit.
|
# the revision — with the same body ripple as a normal edit.
|
||||||
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
|
db.add(NoteRevision(note_id=note.id, body=note.body))
|
||||||
note.title = rev.title
|
|
||||||
note.body = rev.body
|
note.body = rev.body
|
||||||
note.display_title = derive_display_title(note.title, note.body)
|
note.display_title = await _name_for(db, note)
|
||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
|
|||||||
@@ -19,22 +19,30 @@ VALID_FILTERS = {"active", "archived", "trash"}
|
|||||||
DISPLAY_TITLE_CAP = 200
|
DISPLAY_TITLE_CAP = 200
|
||||||
|
|
||||||
|
|
||||||
def derive_display_title(title: str | None, body: str | None) -> str:
|
def derive_display_title(body: str | None, first_item: str | None = None) -> str:
|
||||||
"""The note's display NAME: the explicit title if set, else the first non-empty
|
"""The note's display NAME: the first non-empty line of the body, else the first
|
||||||
line of the body (trimmed, length-capped). Persisted as notes.display_title so a
|
checklist item's text (both trimmed and length-capped).
|
||||||
body-only note is still nameable/searchable/linkable — the user never has to type
|
|
||||||
a title. Deterministic (literal first line, no AI)."""
|
There is no explicit title to prefer any more (M13 step 3) — a note is a body plus
|
||||||
if title and title.strip():
|
optional items, and its name is simply the first thing written in it. Persisted as
|
||||||
return title.strip()[:DISPLAY_TITLE_CAP]
|
notes.display_title so search results and export filenames have something to say.
|
||||||
|
|
||||||
|
The item fallback is what step 2 bought: a note that is only a checklist would
|
||||||
|
otherwise have no name at all, which is exactly the hole that made removing the
|
||||||
|
title unsafe before checklists stopped being their own kind of thing.
|
||||||
|
|
||||||
|
Deterministic — a literal first line, never generated.
|
||||||
|
"""
|
||||||
for line in (body or "").splitlines():
|
for line in (body or "").splitlines():
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped:
|
if stripped:
|
||||||
return stripped[:DISPLAY_TITLE_CAP]
|
return stripped[:DISPLAY_TITLE_CAP]
|
||||||
return ""
|
return (first_item or "").strip()[:DISPLAY_TITLE_CAP]
|
||||||
|
|
||||||
|
|
||||||
def is_empty_note(title: str | None, body: str | None) -> bool:
|
def is_empty_note(body: str | None, items: list | None = None) -> bool:
|
||||||
return not (title or "").strip() and not (body or "").strip()
|
"""Nothing worth keeping: no body text and no checklist items."""
|
||||||
|
return not (body or "").strip() and not items
|
||||||
|
|
||||||
|
|
||||||
def parse_list_items(raw: object) -> list[str]:
|
def parse_list_items(raw: object) -> list[str]:
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
|
|||||||
"""One note as a human-readable Markdown file with a small frontmatter block.
|
"""One note as a human-readable Markdown file with a small frontmatter block.
|
||||||
The authoritative machine format is notes.json; this is for reading/portability."""
|
The authoritative machine format is notes.json; this is for reading/portability."""
|
||||||
fm = ["---"]
|
fm = ["---"]
|
||||||
if note.title:
|
|
||||||
fm.append(f"title: {note.title}")
|
|
||||||
fm.append(f"display_name: {note.display_title}")
|
fm.append(f"display_name: {note.display_title}")
|
||||||
if labels:
|
if labels:
|
||||||
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
|
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
|
||||||
@@ -276,19 +274,29 @@ async def _create_imported_note(
|
|||||||
db, owner_id, spec: dict, zf: zipfile.ZipFile, position: int, budget: _ImportBudget
|
db, owner_id, spec: dict, zf: zipfile.ZipFile, position: int, budget: _ImportBudget
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Insert one imported note plus its items/labels/attachments, reusing the same
|
"""Insert one imported note plus its items/labels/attachments, reusing the same
|
||||||
display-title derivation + tag/link reconciliation as create_note. Returns False
|
name derivation + tag reconciliation as create_note. Returns False (nothing
|
||||||
(nothing written) when the spec is empty."""
|
written) when the spec is empty."""
|
||||||
title = (spec.get("title") or "").strip() or None
|
|
||||||
body = spec.get("body") or ""
|
body = spec.get("body") or ""
|
||||||
|
# An imported title becomes the note's FIRST BODY LINE.
|
||||||
|
#
|
||||||
|
# ThoughtSync has no title field any more (M13 step 3), but the things people
|
||||||
|
# import from do — Keep notes carry one, and so does any export taken before this.
|
||||||
|
# Dropping it would silently lose text someone wrote; folding it into the body puts
|
||||||
|
# it exactly where a name now lives, so the note comes in named the way it was.
|
||||||
|
# Skipped when the body already opens with that line, so re-importing an export
|
||||||
|
# this code produced doesn't stack duplicates.
|
||||||
|
title = (spec.get("title") or "").strip()
|
||||||
|
if title and body.lstrip().split("\n", 1)[0].strip() != title:
|
||||||
|
body = f"{title}\n{body}" if body.strip() else title
|
||||||
|
|
||||||
items = spec.get("items") or []
|
items = spec.get("items") or []
|
||||||
has_items = any((it.get("text") or "").strip() for it in items)
|
item_texts = [t for t in ((it.get("text") or "").strip() for it in items) if t]
|
||||||
if is_empty_note(title, body) and not has_items:
|
if is_empty_note(body, item_texts):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
note = Note(
|
note = Note(
|
||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
title=title,
|
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
|
||||||
display_title=derive_display_title(title, body),
|
|
||||||
body=body,
|
body=body,
|
||||||
color=normalize_color(spec.get("color")),
|
color=normalize_color(spec.get("color")),
|
||||||
pinned=bool(spec.get("pinned")),
|
pinned=bool(spec.get("pinned")),
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
|
|||||||
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
||||||
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
||||||
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||||
note.title = None
|
|
||||||
note.body = ""
|
note.body = ""
|
||||||
note.display_title = ""
|
note.display_title = ""
|
||||||
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
|
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
|
||||||
|
|||||||
+28
-13
@@ -55,13 +55,12 @@ 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.
|
||||||
# v2 (M13): `kind` left the wire. Dropping a field a v1 client sends and expects back
|
# v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends
|
||||||
# is breaking, so the FLOOR moves too — a v1 client would keep pushing a `kind` the
|
# and expects back is breaking, so the FLOOR moves too — a v1 client would keep pushing
|
||||||
# server no longer stores, and would read back notes without one.
|
# both and would read back notes carrying neither.
|
||||||
#
|
#
|
||||||
# `title` goes the same way in step 3. It lands in this same protocol generation, so
|
# One bump for the pair: they landed in the same protocol generation, and nothing ever
|
||||||
# it needs no further bump — v2 means "no kind, no title", and nothing has run against
|
# ran against a half-applied v2.
|
||||||
# a half-applied v2.
|
|
||||||
SYNC_PROTOCOL_VERSION = 2
|
SYNC_PROTOCOL_VERSION = 2
|
||||||
MIN_CLIENT_PROTOCOL_VERSION = 2
|
MIN_CLIENT_PROTOCOL_VERSION = 2
|
||||||
|
|
||||||
@@ -197,8 +196,6 @@ def client_wins(client_edited_at: datetime | None, server_edited_at: datetime |
|
|||||||
def _assign_note_fields(note: Note, ch: dict) -> None:
|
def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||||
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
|
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
|
||||||
whole-note, not a partial patch — the client sends its authoritative version)."""
|
whole-note, not a partial patch — the client sends its authoritative version)."""
|
||||||
title = ch.get("title")
|
|
||||||
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.pinned = bool(ch.get("pinned"))
|
note.pinned = bool(ch.get("pinned"))
|
||||||
@@ -214,6 +211,24 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
|
|||||||
note.position = ch["position"]
|
note.position = ch["position"]
|
||||||
|
|
||||||
|
|
||||||
|
def _first_item_text(ch: dict) -> str:
|
||||||
|
"""The first non-blank checklist item in a pushed change, or "".
|
||||||
|
|
||||||
|
Read straight from the payload rather than the database because the note's name is
|
||||||
|
computed BEFORE `_apply_note_items` has written anything — and a note whose body is
|
||||||
|
empty is named by its first item (M13 step 3).
|
||||||
|
"""
|
||||||
|
items = ch.get("items")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return ""
|
||||||
|
for it in items:
|
||||||
|
if isinstance(it, dict):
|
||||||
|
text = (it.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def _apply_note_items(db, note: Note, ch: dict) -> None:
|
async def _apply_note_items(db, note: Note, ch: dict) -> None:
|
||||||
"""Replace the note's checklist items with the client's (items sync inline).
|
"""Replace the note's checklist items with the client's (items sync inline).
|
||||||
|
|
||||||
@@ -296,14 +311,14 @@ async def _apply_note(db, ch: dict) -> dict:
|
|||||||
elif note.purged_at is not None:
|
elif note.purged_at is not None:
|
||||||
note.purged_at = None # client re-created/edited → clear the tombstone
|
note.purged_at = None # client re-created/edited → clear the tombstone
|
||||||
|
|
||||||
old_title, old_body = note.title, note.body
|
old_body = note.body
|
||||||
_assign_note_fields(note, ch)
|
_assign_note_fields(note, ch)
|
||||||
note.display_title = derive_display_title(note.title, note.body)
|
note.display_title = derive_display_title(note.body, _first_item_text(ch))
|
||||||
if edited_at is not None:
|
if edited_at is not None:
|
||||||
note.updated_at = edited_at
|
note.updated_at = edited_at
|
||||||
# Non-destructive LWW: snapshot the overwritten server title+body into history.
|
# Non-destructive LWW: snapshot the overwritten server body into history.
|
||||||
if not creating and (note.title != old_title or note.body != old_body):
|
if not creating and note.body != old_body:
|
||||||
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
db.add(NoteRevision(note_id=note.id, body=old_body))
|
||||||
await db.flush() # assign note.id before items/labels/links
|
await db.flush() # assign note.id before items/labels/links
|
||||||
await _apply_note_items(db, note, ch)
|
await _apply_note_items(db, note, ch)
|
||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
|
|||||||
+28
-21
@@ -51,9 +51,10 @@ def test_all_note_routes_registered(app):
|
|||||||
|
|
||||||
def test_is_empty_note():
|
def test_is_empty_note():
|
||||||
assert is_empty_note(None, None)
|
assert is_empty_note(None, None)
|
||||||
assert is_empty_note("", " ")
|
assert is_empty_note(" ", [])
|
||||||
assert not is_empty_note("title", "")
|
assert not is_empty_note("body")
|
||||||
assert not is_empty_note("", "body")
|
# A note that is only a checklist is not empty — it just has nothing in its body.
|
||||||
|
assert not is_empty_note("", ["milk"])
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_color():
|
def test_normalize_color():
|
||||||
@@ -69,9 +70,9 @@ def test_palette_has_core_colors():
|
|||||||
|
|
||||||
|
|
||||||
def test_serialize_shape():
|
def test_serialize_shape():
|
||||||
n = Note(title="t", body="b", color="blue", pinned=True, archived=False)
|
n = Note(body="b", color="blue", pinned=True, archived=False)
|
||||||
s = n.serialize()
|
s = n.serialize()
|
||||||
assert s["title"] == "t"
|
assert "title" not in s # there is no title field any more (M13 step 3)
|
||||||
assert s["body"] == "b"
|
assert s["body"] == "b"
|
||||||
assert s["color"] == "blue"
|
assert s["color"] == "blue"
|
||||||
assert s["pinned"] is True
|
assert s["pinned"] is True
|
||||||
@@ -122,30 +123,34 @@ async def test_reorder_requires_auth(app):
|
|||||||
# notice a route coming back, and the removal is one commit rather than a fossil.
|
# notice a route coming back, and the removal is one commit rather than a fossil.
|
||||||
|
|
||||||
|
|
||||||
def test_derive_display_title_explicit_wins():
|
def test_derive_display_title_is_the_first_body_line():
|
||||||
assert derive_display_title("My Title", "some body line") == "My Title"
|
assert derive_display_title("first line\nsecond line") == "first line"
|
||||||
assert derive_display_title(" Padded ", "body") == "Padded"
|
assert derive_display_title(" spaced first \nnext") == "spaced first"
|
||||||
|
|
||||||
|
|
||||||
def test_derive_display_title_from_first_body_line():
|
|
||||||
assert derive_display_title(None, "first line\nsecond line") == "first line"
|
|
||||||
assert derive_display_title("", " spaced first \nnext") == "spaced first"
|
|
||||||
# leading blank/whitespace lines are skipped to the first line with content
|
# leading blank/whitespace lines are skipped to the first line with content
|
||||||
assert derive_display_title(None, "\n \nreal line\nmore") == "real line"
|
assert derive_display_title("\n \nreal line\nmore") == "real line"
|
||||||
# a whitespace-only title falls through to the body
|
|
||||||
assert derive_display_title(" ", "body wins") == "body wins"
|
|
||||||
|
def test_derive_display_title_falls_back_to_the_first_item():
|
||||||
|
# What step 2 bought: a note that is only a checklist still has a name. Without
|
||||||
|
# this it would have none at all, which is why the title could not go first.
|
||||||
|
assert derive_display_title("", "milk") == "milk"
|
||||||
|
assert derive_display_title(" \n ", " eggs ") == "eggs"
|
||||||
|
# The body still wins when it has anything to say.
|
||||||
|
assert derive_display_title("shopping", "milk") == "shopping"
|
||||||
|
|
||||||
|
|
||||||
def test_derive_display_title_empty():
|
def test_derive_display_title_empty():
|
||||||
assert derive_display_title(None, None) == ""
|
assert derive_display_title(None) == ""
|
||||||
assert derive_display_title("", "") == ""
|
assert derive_display_title("") == ""
|
||||||
assert derive_display_title(" ", " \n ") == ""
|
assert derive_display_title(" \n ", None) == ""
|
||||||
|
assert derive_display_title(" \n ", " ") == ""
|
||||||
|
|
||||||
|
|
||||||
def test_derive_display_title_caps_length():
|
def test_derive_display_title_caps_length():
|
||||||
long = "x" * 300
|
long = "x" * 300
|
||||||
assert derive_display_title(None, long) == "x" * 200
|
assert derive_display_title(long) == "x" * 200
|
||||||
assert derive_display_title(long, "body") == "x" * 200
|
# the item fallback is capped on the same rule
|
||||||
|
assert derive_display_title("", long) == "x" * 200
|
||||||
|
|
||||||
|
|
||||||
def test_parse_tags():
|
def test_parse_tags():
|
||||||
@@ -371,6 +376,8 @@ def test_native_spec_roundtrip_fields():
|
|||||||
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
|
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
|
||||||
}
|
}
|
||||||
spec = _native_spec(n)
|
spec = _native_spec(n)
|
||||||
|
# The spec still CARRIES a title — an export taken before M13 has one, and
|
||||||
|
# _create_imported_note folds it into the body rather than dropping it.
|
||||||
assert spec["title"] == "T"
|
assert spec["title"] == "T"
|
||||||
assert spec["body"] == "b"
|
assert spec["body"] == "b"
|
||||||
assert spec["color"] == "blue"
|
assert spec["color"] == "blue"
|
||||||
|
|||||||
Reference in New Issue
Block a user