2 Commits
Author SHA1 Message Date
bvandeusen 1b3e29e4f6 0.2.0 — a notebook in your pocket, ready to be hosted (#3)
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 15s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
2026-08-23 16:38:00 -04:00
bvandeusen 867405fae2 M12 — the Android client, end to end (#2)
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m8s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m13s
CI & Build / Python tests (push) Successful in 11s
86 commits from dev. Native Kotlin/Compose Android client over the shared Rust
core, the server-served distribution path, signing, and self-update.

Known and recorded rather than fixed: #2810 (release APK carries a debug-profile
.so), allowBackup still true now that the store holds a device token, and
cleartext HTTP enabled app-wide for self-hosted LAN servers.
2026-08-21 08:53:57 -04:00
44 changed files with 1212 additions and 3058 deletions
+6 -11
View File
@@ -193,7 +193,8 @@ jobs:
# them ever executed by CI — and the schema the migrations build had never been # them ever executed by CI — and the schema the migrations build had never been
# checked against the models that read it. # checked against the models that read it.
# #
# Gates the build, along with every other lane — see the `build` job's `needs`. # Runs for visibility and does NOT gate the build, matching the `test` lane and
# FabledScribe's equivalent job.
# #
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner # Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
# derives the service-container name from the truncated job display name, and the # derives the service-container name from the truncated job display name, and the
@@ -260,16 +261,10 @@ jobs:
build: build:
name: Build & push image name: Build & push image
# Every lane gates the build. This once stopped at lint + typecheck, on the # Build gates on lint + typecheck. The `test` job runs in parallel for
# reasoning that DB-backed testing happened manually against the dev image # visibility but does not block dev image builds (DB-backed integration
# rather than on every push — true until 6f21db8 added the integration lane, # testing happens against the dev image manually, not on every push).
# and false since. needs: [gate, typecheck, lint]
#
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
# anyway, so the deployed server ran a build whose test lane was red. An image
# tag is the rollback substrate (family rule 46); one that can be published
# from a failing run is not a substrate you can roll back TO.
needs: [gate, typecheck, lint, test, integration]
if: needs.gate.outputs.build == 'true' if: needs.gate.outputs.build == 'true'
runs-on: python-ci runs-on: python-ci
container: container:
@@ -1,128 +0,0 @@
"""fold note_items into the note body and drop the table
Revision ID: 0027
Revises: 0026
Create Date: 2026-08-24
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
goes. The reason is positional, not cosmetic: a row had a position in a table and no
position in the text, so a separate list could only ever render AFTER the prose. With
the items in the body, a list can sit between two paragraphs — which is the thing that
could not be built before and no amount of restyling would have delivered.
## This migration rewrites note bodies
Every note that has items gets its body appended to. The rules below are strict
because rewriting somebody's text deserves it — not, as an earlier draft of this
docstring claimed, because this instance holds imported Google Keep notes. It does
not; note 2916's headline is that nothing here is anyone's work but the operator's
test data. What 2916 actually says about imports is conditional — text arriving from
another app WOULD be real, and any import path has to treat it that way — and the
importer this migration shares a format with is one nobody here has run.
Careful was still the right call. It cost little, and the same care is what the rule
demands the day someone does import something:
* Rows are read BEFORE the table is dropped, in this one transaction.
* The existing body is never rewritten, only appended to.
* The layout — a blank line between prose and the list, nothing between consecutive
items — is byte-for-byte what `_note_markdown` has always exported and what
`derive::append_item` produces on every client. All three landing on the same text
is what lets the clients migrate their own SQLite stores independently and still
agree with the server, with no sync required to reconcile them.
## The fold is inlined on purpose
`notes/checklist.py` has this same function and this migration deliberately does not
import it. A migration has to keep producing what it produced the day it ran; if the
app's spacing rule ever changes, this file must not change with it.
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
more than the first. Every client folds the same rows the same way, so the new body is
news to nobody. And a client holding an UNPUSHED body edit still has the newer
`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead
of the migration silently winning.
The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every
migrated note becomes pullable once. That is wanted: it is what makes a client whose
local fold somehow differed converge on the server's text.
## The downgrade is not a true inverse, and says so
It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost —
every item is still there as text, which is where this migration put it — but the old
code would show those notes as prose with no checklist. A faithful inverse is not
possible: once the items are lines, nothing distinguishes a line this migration wrote
from one somebody typed, and a downgrade that guessed would eat hand-written task
lists. The real rollback is a database restore.
Recreating the table is not decoration, though. Migration 0015's downgrade runs
`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS`
covers the trigger, not the table — against a missing table that statement errors. So
this is what keeps the migration chain runnable all the way back down.
"""
import re
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
_TASK_RE = re.compile(r"^\s*[-*] +\[[ xX]\](?: +.*)?$")
def _append_item(body: str, text: str, checked: bool) -> str:
mark = "x" if checked else " "
text = (text or "").strip()
line = f"- [{mark}] {text}" if text else f"- [{mark}]"
trimmed = (body or "").rstrip("\n")
if not trimmed.strip():
return line
follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1]))
return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}"
def upgrade():
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT note_id, text, checked FROM note_items ORDER BY note_id, position, created_at")
).fetchall()
grouped: dict = {}
for note_id, text, checked in rows:
grouped.setdefault(note_id, []).append((text, bool(checked)))
for note_id, items in grouped.items():
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
# An item whose note is already gone has nothing to fold into. The foreign key
# should make this impossible; skipping costs nothing and failing here would
# leave the database half-migrated.
if body is None:
continue
for text, checked in items:
body = _append_item(body, text, checked)
bind.execute(sa.text("UPDATE notes SET body = :body WHERE id = :id"), {"body": body, "id": note_id})
op.drop_table("note_items")
def downgrade():
# Column-for-column as migration 0006 created it, index name included: 0015's
# downgrade names both the table and its trigger, so a near-enough copy is not
# good enough.
op.create_table(
"note_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("checked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_note_items_note", "note_items", ["note_id"])
@@ -26,6 +26,7 @@ import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardSync import com.fabledsword.thoughtsync.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardViewModel import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ComposeSheet
import com.fabledsword.thoughtsync.ui.ForegroundTransitions import com.fabledsword.thoughtsync.ui.ForegroundTransitions
import com.fabledsword.thoughtsync.ui.NoteEditorScreen import com.fabledsword.thoughtsync.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
@@ -140,13 +141,14 @@ private fun App(
val sync: SyncViewModel = val sync: SyncViewModel =
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh)) viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
// Screen visibility is view STATE, not view-model state: it is about what is on // Sheet and screen visibility are view STATE, not view-model state: they are
// the display, and nothing in the store cares. Saveable so a rotation does not // about what is on the display, and nothing in the store cares.
// close it.
// //
// The capture sheet used to keep its own flag here too. It is gone: the + button // Saveable, though: `remember` alone meant rotating the phone closed whatever
// opens the editor on an unsaved draft, so writing a note and editing one are the // was open and took the half-written note in the capture sheet with it. The
// same surface with the same toolbar. // editor never had that problem because the note it is on lives in a view
// model; these two are the only screen state that did not.
var composing by rememberSaveable { mutableStateOf(false) }
var showingSync by rememberSaveable { mutableStateOf(false) } var showingSync by rememberSaveable { mutableStateOf(false) }
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context)) val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
@@ -190,7 +192,6 @@ private fun App(
NoteEditorScreen( NoteEditorScreen(
// Non-null by construction: `screen` is EDITOR only when it is. // Non-null by construction: `screen` is EDITOR only when it is.
note = requireNotNull(editing) { "the editor screen needs a note" }, note = requireNotNull(editing) { "the editor screen needs a note" },
sessionKey = board.state.editingSession,
labels = board.state.labels, labels = board.state.labels,
saving = board.state.saving, saving = board.state.saving,
error = board.state.error, error = board.state.error,
@@ -217,10 +218,20 @@ private fun App(
), ),
onOpenSync = { showingSync = true }, onOpenSync = { showingSync = true },
onSearch = board::search, onSearch = board::search,
onCompose = board::compose, onCompose = { composing = true },
onToggleItem = board::toggleItem,
onDismissError = board::dismissError, onDismissError = board::dismissError,
) )
if (composing) {
ComposeSheet(
saving = board.state.saving,
onDismiss = { composing = false },
onSave = { content ->
board.create(content)
composing = false
},
)
}
} }
} }
@@ -1,224 +0,0 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* The note's body, as fields and checkboxes rather than as markup.
*
* The point of the whole shape: a box you can tick while looking at the note, rather
* than `- [ ] ` to read and edit around. What the note IS never changed.
*/
@Composable
fun BlockBody(
blocks: List<EditorBlock>,
readOnly: Boolean,
focus: Long?,
onChange: (List<EditorBlock>) -> Unit,
onFocus: (Long?) -> Unit,
modifier: Modifier = Modifier,
) {
// Focus is addressed by block ID, never by position — the id is the only thing
// about a block that survives one being inserted above it. Hoisted to the caller
// rather than kept here, because the TOOLBAR also asks for a focus when its button
// appends an item, and two owners of one cursor is one too many.
val requesters = remember { mutableMapOf<Long, FocusRequester>() }
LaunchedEffect(focus) {
val id = focus ?: return@LaunchedEffect
// Honoured after the composition that created the field: a FocusRequester not
// yet attached to anything throws when asked.
requesters[id]?.requestFocus()
onFocus(null)
}
fun replace(
index: Int,
block: EditorBlock,
) = onChange(blocks.toMutableList().also { it[index] = block })
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
blocks.forEachIndexed { index, block ->
val requester = requesters.getOrPut(block.id) { FocusRequester() }
if (block.isTask) {
TaskBlock(
block = block,
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onEnter = {
val next = blocks.nextId()
onChange(afterEnter(blocks, index, next))
// The new item if there was one; otherwise the block that just
// became prose, which keeps the caret where the person left it.
onFocus(if (blocks[index].value.text.isBlank()) block.id else next)
},
onDelete = {
val remaining = blocks.withoutIndex(index)
onChange(remaining)
// The row above — or, for the FIRST row, whichever one takes
// its place. `index - 1` alone is -1 there, which left the
// keyboard up with nothing focused.
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
},
)
} else {
ProseBlock(
block = block,
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
)
}
}
}
}
/** A run of prose: one ordinary multi-line field, exactly as the editor always had. */
@Composable
private fun ProseBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
) {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.focusRequester(requester),
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
}
/**
* One checklist item: a real box, and the item's text beside it.
*
* Single-line with [ImeAction.Next], which is what turns the keyboard's return key
* into "next item" — the reason a list can be typed straight through rather than a
* marker at a time.
*/
@Composable
private fun TaskBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onEnter: () -> Unit,
onDelete: () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = block.checked == true,
onCheckedChange = { onChange(block.copy(checked = it)) },
enabled = !readOnly,
)
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.weight(1f).focusRequester(requester),
enabled = !readOnly,
singleLine = true,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
// Struck through when done, matching the card and the web.
textDecoration =
if (block.checked == true) TextDecoration.LineThrough else null,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { onEnter() }),
)
if (!readOnly) {
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_item),
)
}
}
}
}
/**
* The field a block is typed into.
*
* `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is
* density. Material's TextField puts 16dp above and below its text — padding that
* makes a FORM field comfortable to hit, and that on a checklist IS the row height. It
* made six items twice as tall as the six items, which is what the operator saw.
*
* Nothing is lost by dropping down a layer. `PlainTextField` exists to strip a
* container and an indicator; `BasicTextField` never had either, so there is no box
* here to drift back into existence. What it does not supply and this must:
*
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK — the same
* default that made the editor's toolbar invisible in dark mode. Set, not inherited.
* - the cursor brush, which would otherwise be black for the same reason.
* - the placeholder, which is a plain Text behind the field rather than a slot.
*
* `enabled = false` deliberately does not grey the text out: a trashed note renders
* read-only through this and its words are meant to be READ.
*/
@Composable
private fun BlockField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
singleLine: Boolean = false,
@StringRes hint: Int? = null,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
Box(modifier = modifier) {
if (hint != null && value.text.isEmpty()) {
Text(
text = stringResource(hint),
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
singleLine = singleLine,
textStyle = style,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
)
}
}
@@ -64,7 +64,6 @@ fun BoardScreen(
onOpenSync: () -> Unit, onOpenSync: () -> Unit,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onCompose: () -> Unit, onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onDismissError: () -> Unit, onDismissError: () -> Unit,
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -141,12 +140,7 @@ fun BoardScreen(
when { when {
state.loading -> LoadingBoard() state.loading -> LoadingBoard()
state.notes.isEmpty() -> EmptyBoard(state) state.notes.isEmpty() -> EmptyBoard(state)
else -> else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
NoteBoard(
notes = state.notes,
onOpenNote = onOpenNote,
onToggleItem = onToggleItem,
)
} }
// `PullToRefreshBox` would be less code, but it takes no // `PullToRefreshBox` would be less code, but it takes no
// `enabled`, so the modifier and the indicator are wired by // `enabled`, so the modifier and the indicator are wired by
@@ -329,7 +323,6 @@ private fun DrawerRow(
private fun NoteBoard( private fun NoteBoard(
notes: List<Note>, notes: List<Note>,
onOpenNote: (Note) -> Unit, onOpenNote: (Note) -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
) { ) {
LazyVerticalStaggeredGrid( LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS), columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
@@ -343,11 +336,7 @@ private fun NoteBoard(
// rebuilding them — and so a newly captured note slides in instead of // rebuilding them — and so a newly captured note slides in instead of
// making every card below it flicker. // making every card below it flicker.
items(items = notes, key = { it.id }) { note -> items(items = notes, key = { it.id }) { note ->
NoteCard( NoteCard(note = note, onOpen = { onOpenNote(note) })
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
)
} }
} }
} }
@@ -68,16 +68,6 @@ data class BoardState(
* has to re-query to see its own change. * has to re-query to see its own change.
*/ */
val editing: Note? = null, val editing: Note? = null,
/**
* Bumped each time the editor is opened on a DIFFERENT note, and deliberately
* not when the note it is already on changes.
*
* The editor keys its text field on this rather than on `editing.id`, because a
* draft's id changes the instant it is first saved — and re-keying on that would
* reset the field to whatever the store just returned, discarding anything typed
* during the write. That is a data-loss bug rather than a flicker.
*/
val editingSession: Long = 0,
) { ) {
/** Search overrides the destination while there is a query to run. */ /** Search overrides the destination while there is a query to run. */
val searching: Boolean get() = query.isNotBlank() val searching: Boolean get() = query.isNotBlank()
@@ -198,6 +188,44 @@ class BoardViewModel(
} }
} }
/**
* Save a new note or list.
*
* Blank input is ignored rather than rejected: an empty save is a slip, not a
* mistake worth interrupting someone over.
*/
fun create(content: String) {
val cleanContent = content.trim()
if (cleanContent.isEmpty()) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
// Prepend rather than reload: the new note belongs at the top
// of the board, and a full re-query would cost a round trip to
// tell us what we already know. Skipped when the board is not
// showing plain notes — a note created while looking at Trash
// does not belong in that list.
val notes =
if (state.destination == Destination.Notes && !state.searching) {
listOf(created) + state.notes
} else {
state.notes
}
// A capture sheet can carry a reminder in its text one day;
// more to the point, this is a store write and the rule here is
// that every store write re-derives the alarm rather than each
// call site deciding whether its particular write could matter.
withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy(notes = notes, saving = false, error = null)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
// ─────────────────────────────── the editor ────────────────────────────── // ─────────────────────────────── the editor ──────────────────────────────
/** /**
@@ -210,111 +238,12 @@ class BoardViewModel(
fun openNoteById(id: String) { fun openNoteById(id: String) {
viewModelScope.launch { viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } } runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) } .onSuccess { state = state.copy(editing = it) }
} }
} }
fun openNote(note: Note) { fun openNote(note: Note) {
state = state.copy(editing = note, editingSession = state.editingSession + 1) state = state.copy(editing = note)
}
/**
* Open the editor on a note that does not exist yet.
*
* The + button used to raise a separate capture sheet, which meant a note being
* WRITTEN could not be given a colour, a reminder or a checklist — those live on
* the editor's toolbar, and the sheet had none. Writing and editing are now the
* same surface.
*
* The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the
* editor renders it without knowing that "not saved yet" is a state it can be
* in. It becomes a row on its first save; see [onDraftAction].
*/
fun compose() {
draftDismissed = false
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
}
/**
* Set when a draft's editor closes, so a create still in flight does not reopen
* it. The editor flushes its text and then closes, and the flush is a coroutine —
* without this the note would be created, the screen would close, and the create
* would finish and put the screen back.
*/
private var draftDismissed = false
/**
* The editor's actions, for a note that has no row yet.
*
* Everything a toolbar button does needs an id to act on, so the first action
* that needs one creates the note and replays itself against the real thing.
*/
private fun onDraftAction(
draft: Note,
action: EditorAction,
) {
when (action) {
// Nothing exists, so leaving leaves nothing behind — which is what makes
// tapping + and changing your mind free. Text typed before this point has
// already gone to createFromDraft via the editor's autosave or its flush.
EditorAction.Close, EditorAction.Trash -> {
draftDismissed = true
state = state.copy(editing = null)
}
EditorAction.DismissError -> dismissError()
is EditorAction.SaveText -> createFromDraft(action.body)
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
// a note. With autosave at a second, "typed something" is true by the time
// anyone reaches the toolbar; before that there is nothing to attribute.
else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) }
}
}
/**
* Turn a draft into a row, and keep the editor on it.
*
* Adopting the created note is what lets a session of autosaves stay one note:
* the second save sees a real id and updates rather than creating again.
*/
private fun createFromDraft(
content: String,
allowEmpty: Boolean = false,
then: (Note) -> Unit = {},
) {
val cleanContent = content.trim()
// A blank draft is not a note. Ignored rather than rejected: tapping + and
// walking away is a slip, not a mistake worth interrupting someone over.
if (cleanContent.isEmpty() && !allowEmpty) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
// Prepend rather than reload: the new note belongs at the top of
// the board, and a full re-query would cost a round trip to tell
// us what we already know. Skipped when the board is not showing
// plain notes — a note created while looking at Trash does not
// belong in that list.
val notes =
if (state.destination == Destination.Notes && !state.searching) {
listOf(created) + state.notes
} else {
state.notes
}
withContext(Dispatchers.IO) { onRemindersChanged() }
// editingSession is NOT bumped: this is the same sitting, and the
// editor's field must not be re-keyed underneath the typing.
state.copy(
notes = notes,
editing = if (draftDismissed) state.editing else created,
saving = false,
error = null,
)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
if (!draftDismissed) state.editing?.let(then)
}
} }
/** /**
@@ -339,18 +268,13 @@ class BoardViewModel(
note: Note, note: Note,
action: EditorAction, action: EditorAction,
) { ) {
if (note.id == DRAFT_ID) {
onDraftAction(note, action)
return
}
val id = note.id val id = note.id
when (action) { when (action) {
EditorAction.Close -> state = state.copy(editing = null) EditorAction.Close -> state = state.copy(editing = null)
EditorAction.DismissError -> dismissError() EditorAction.DismissError -> dismissError()
// Sent on an idle debounce while typing, and again on close. Writing // Saved on close rather than per keystroke, so a session of typing
// this often is affordable because a body write no longer snapshots a // costs one write and one revision snapshot.
// revision — the core keeps one per editing session, not one per save.
is EditorAction.SaveText -> is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) } mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
@@ -378,6 +302,20 @@ class BoardViewModel(
null null
} }
// An empty first item: the checklist editor appears the moment the note
// has one, and an empty row is what someone can type straight into.
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
is EditorAction.AddItem ->
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
mutate { it.addItem(id, text) }
}
is EditorAction.SetItemChecked ->
mutate { it.setItemChecked(id, action.itemId, action.checked) }
is EditorAction.SetItemText ->
mutate { it.setItemText(id, action.itemId, action.text) }
is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) }
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) } is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
is EditorAction.CreateLabel -> is EditorAction.CreateLabel ->
@@ -413,10 +351,9 @@ class BoardViewModel(
/** /**
* The one path every store mutation takes. * The one path every store mutation takes.
* *
* Each core mutation returns the reloaded note, which refreshes * Each core mutation returns the reloaded note, which goes straight into
* [BoardState.editing] so an OPEN editor shows its own change without a * [BoardState.editing] so an open editor shows its own change without a
* re-query — and does nothing at all when the editor is closed, because that * re-query. The BOARD list is then reloaded rather than patched in place:
* field doubles as "which screen is up". The BOARD list is then reloaded rather than patched in place:
* pinning re-sorts it, archiving removes the note from it, and adding a label * pinning re-sorts it, archiving removes the note from it, and adding a label
* can move it in or out of a label view — a splice would have to reimplement * can move it in or out of a label view — a splice would have to reimplement
* the core's ordering and membership rules in Kotlin to get any of that right. * the core's ordering and membership rules in Kotlin to get any of that right.
@@ -452,12 +389,7 @@ class BoardViewModel(
withContext(Dispatchers.IO) { onRemindersChanged() } withContext(Dispatchers.IO) { onRemindersChanged() }
state.copy( state.copy(
notes = notes, notes = notes,
// Only REFRESHES an open editor; it must never open one. editing = if (closeEditor) null else updated ?: state.editing,
// `editing != null` IS "the editor is on screen", so writing
// the reloaded note in unconditionally meant any mutation
// started from the BOARD threw the editor open on top of it —
// which is exactly what ticking a checkbox on a card did.
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
saving = false, saving = false,
error = null, error = null,
) )
@@ -470,21 +402,6 @@ class BoardViewModel(
} }
} }
/**
* Tick or untick one item from the BOARD, without opening the note.
*
* The common gesture on a checklist, and the reason it goes through the store
* rather than the pure text helpers the editor uses: nothing here is holding a
* half-typed body, so the reloaded note is simply the truth.
*
* `index` is the item's ordinal, which is what its id is now (M304).
*/
fun toggleItem(
note: Note,
index: Int,
checked: Boolean,
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
fun dismissError() { fun dismissError() {
state = state.copy(error = null) state = state.copy(error = null)
} }
@@ -530,33 +447,3 @@ private fun draft(content: String): NoteDraft =
// findable without anyone being asked to name it. A checklist is added afterwards, // findable without anyone being asked to name it. A checklist is added afterwards,
// in the editor — it is something a note HAS, not a different thing to capture. // in the editor — it is something a note HAS, not a different thing to capture.
NoteDraft(body = content, color = DEFAULT_COLOR, items = null) NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
/**
* The id a note has before it has been saved.
*
* A real id is a uuid, so the empty string cannot collide with one. Using a sentinel
* rather than making the editor's note nullable keeps "not saved yet" out of a screen
* that reads eight fields off the note and should not have to null-check any of them.
*/
internal const val DRAFT_ID = ""
private fun blankDraft(): Note =
Note(
id = DRAFT_ID,
displayTitle = "",
body = "",
color = DEFAULT_COLOR,
position = 0,
pinned = false,
archived = false,
trashed = false,
deletedAt = null,
remindAt = null,
recurrence = null,
labels = emptyList(),
items = emptyList(),
attachments = emptyList(),
previews = emptyList(),
createdAt = null,
updatedAt = null,
)
@@ -0,0 +1,130 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* The new-note surface, opened by the + button.
*
* A bottom sheet rather than a full screen: capture should feel like a quick aside
* from the board, not a place you navigate to and have to come back from. The
* board stays visible behind it, so the note lands somewhere you can already see.
*
* It asks note-or-list up front rather than making that a mode you discover later,
* because on a phone the two are genuinely different typing tasks and switching
* halfway is worse than choosing at the start.
*
* ## Leaving keeps what you wrote
*
* Every way out of this sheet except Discard SAVES: the save button, tapping the
* board behind it, swiping down, back, and the app being backgrounded. A sheet
* that throws away a typed thought because you touched outside it is a sheet that
* teaches people not to trust the app with a thought — and capture is the one
* place this product cannot afford that.
*
* The same shape the editor settled on, for the same reason, with one difference:
* capture also has to be abandonable, because tapping + and changing your mind is
* a normal thing to do. That is what Discard is, and it is the only path that
* loses anything. An empty draft needs neither — it is simply dropped, since a
* blank note nobody asked for is worse than no note at all.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComposeSheet(
saving: Boolean,
onDismiss: () -> Unit,
onSave: (String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Saveable, not just remembered: a rotation mid-sentence is the same lost
// thought as a discarded one, and it was losing it before this.
var content by rememberSaveable { mutableStateOf("") }
val contentFocus = remember { FocusRequester() }
val written = content.isNotBlank()
val leave = { if (written) onSave(content) else onDismiss() }
// Straight into the one field there is. A capture is a thought, and every field
// someone has to tab past is the difference 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() }
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
// + and then got distracted should find the composer where they left it; the
// only reason to act here is that there is something to lose.
FlushOnStop { if (written) onSave(content) }
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.imePadding()
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// No note/list switch any more: there is one thing to capture. A
// checklist is added to a note in the editor, once there is a note.
PlainTextField(
value = content,
onValueChange = { content = it },
modifier = Modifier.focusRequester(contentFocus),
hint = R.string.compose_body_hint,
minLines = MIN_CONTENT_LINES,
)
SheetActions(
canSave = !saving && written,
onDiscard = onDismiss,
onSave = { onSave(content) },
)
}
}
}
@Composable
private fun SheetActions(
canSave: Boolean,
onDiscard: () -> Unit,
onSave: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.End,
) {
// "Discard", not "Cancel". Cancel means "undo what I am doing", which is
// precisely what leaving no longer does — the word would now describe the
// one button it is NOT attached to.
TextButton(onClick = onDiscard) { Text(stringResource(R.string.compose_discard)) }
Button(onClick = onSave, enabled = canSave) {
Text(stringResource(R.string.compose_save))
}
}
}
private const val MIN_CONTENT_LINES = 4
@@ -29,6 +29,15 @@ sealed interface EditorAction {
val color: String, val color: String,
) : EditorAction ) : EditorAction
/**
* Give this note a checklist.
*
* Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
* so nothing moves and nothing is swapped: the body stays exactly where it is and
* the note gains a first, empty item for someone to type into.
*/
data object AddChecklist : EditorAction
data class SetPinned( data class SetPinned(
val pinned: Boolean, val pinned: Boolean,
) : EditorAction ) : EditorAction
@@ -43,12 +52,23 @@ sealed interface EditorAction {
data object DeleteForever : EditorAction data object DeleteForever : EditorAction
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the data class AddItem(
// body, so adding, renaming, ticking or deleting one is editing text — which the val text: String,
// editor already does, through SaveText, with the same autosave and the same ) : EditorAction
// revision window as any other edit. Routing them through the store would have
// meant the store handing back a note whose body disagreed with the field the data class SetItemChecked(
// person was typing in. val itemId: String,
val checked: Boolean,
) : EditorAction
data class SetItemText(
val itemId: String,
val text: String,
) : EditorAction
data class DeleteItem(
val itemId: String,
) : EditorAction
/** /**
* The note's MANUAL labels, replacing whatever was there. * The note's MANUAL labels, replacing whatever was there.
@@ -1,155 +0,0 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.fabledsword.thoughtsync.core.checklistItems
import com.fabledsword.thoughtsync.core.checklistRender
/**
* One piece of a note body, as the editor DRAWS it.
*
* The note is still one markdown string underneath (M304) — this is a rendering and
* input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
* string back together on every edit.
*
* A run of prose lines is ONE block rather than one per line. Typing a paragraph has
* to feel like typing a paragraph, and a separate field under every sentence would
* break the caret in the middle of writing. Only a checklist item earns a block of its
* own, because only a checklist item needs a widget.
*
* The block owns its [TextFieldValue], not just its text, so a caret survives an edit
* to some other block. And [id] is stable across edits: Compose keys fields by
* position unless told otherwise, so inserting an item above one would otherwise move
* everyone's caret up a row. Content cannot serve as that key — two empty items are
* identical and neither is the other.
*/
data class EditorBlock(
val id: Long,
val value: TextFieldValue,
/** null for prose; ticked-or-not for a checklist item. */
val checked: Boolean?,
) {
val isTask: Boolean get() = checked != null
}
/**
* Split a body into blocks, numbering them from [firstId].
*
* Which lines are items comes from the core, not from a pattern here — the grammar is
* written three times already and Kotlin is not going to be the fourth.
*/
fun splitBlocks(
body: String,
firstId: Long = 0,
): List<EditorBlock> {
val itemAt = checklistItems(body).associateBy { it.line.toInt() }
val out = mutableListOf<EditorBlock>()
val prose = mutableListOf<String>()
var id = firstId
fun flushProse() {
if (prose.isNotEmpty()) {
out += EditorBlock(id++, TextFieldValue(prose.joinToString("\n")), null)
prose.clear()
}
}
body.split("\n").forEachIndexed { n, line ->
val item = itemAt[n]
if (item == null) {
prose += line
} else {
flushProse()
out += EditorBlock(id++, TextFieldValue(item.text), item.checked)
}
}
flushProse()
// Never empty: an empty note still needs one field to type into.
return out.ifEmpty { listOf(EditorBlock(id, TextFieldValue(""), null)) }
}
/**
* The body those blocks stand for — byte-identical to what [splitBlocks] was given,
* for a body already in canonical form. A non-canonical one (`- [X]`, an odd bullet)
* comes back canonical, which is the same rule every other rewriter in `derive`
* follows.
*/
fun joinBlocks(blocks: List<EditorBlock>): String =
blocks.joinToString("\n") { block ->
val checked = block.checked
if (checked == null) block.value.text else checklistRender(block.value.text, checked)
}
/**
* Rotation carries the TEXT and re-derives the shape.
*
* Blocks are not parcelable and their ids are meaningless across a process death, so
* the body string is the honest thing to save — it is the real state, and everything
* else about a block is derived from it.
*/
val blocksSaver: Saver<List<EditorBlock>, String> =
Saver(save = { joinBlocks(it) }, restore = { splitBlocks(it) })
/**
* What the return key does on a checklist item.
*
* `internal` rather than private because BlockBody.kt calls it. These three helpers
* are the block MODEL and the composables are the block UI — one file was doing both,
* which detekt noticed by counting functions before anybody noticed by reading.
*
* On one with words in it, a new empty item below. On an EMPTY one, the item becomes
* prose — which is how a list ENDS, and the same rule the plain text field used
* before this: without it a list is impossible to get out of.
*
* Deliberately appends rather than splitting at the caret. Splitting an item in two is
* a rarity, and the caret is at the end for every ordinary use of this key.
*/
internal fun afterEnter(
blocks: List<EditorBlock>,
index: Int,
newId: Long,
): List<EditorBlock> {
val block = blocks[index]
val out = blocks.toMutableList()
if (block.value.text.isBlank()) {
out[index] = block.copy(value = TextFieldValue(""), checked = null)
} else {
out.add(index + 1, EditorBlock(newId, TextFieldValue(""), false))
}
return out
}
/** Drop a block, leaving at least one field to type into. */
internal fun List<EditorBlock>.withoutIndex(index: Int): List<EditorBlock> {
val out = toMutableList().also { it.removeAt(index) }
return out.ifEmpty { listOf(EditorBlock(nextId(), TextFieldValue(""), null)) }
}
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
internal fun List<EditorBlock>.nextId(): Long = (maxOfOrNull { it.id } ?: -1L) + 1L
/**
* One more empty checklist item at the end, and the id to put the caret in.
*
* What the toolbar's checklist button does. It appends rather than inserting at the
* caret because a block editor has no single caret to insert at — the field that had
* focus may not even be the one being looked at by the time this runs.
*/
fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
val id = nextId()
return (this + EditorBlock(id, TextFieldValue(""), false)) to id
}
/**
* Put the caret at the end of the last block, for an editor that has just opened.
*
* Opening an existing note means continuing it, and a caret at offset zero would put
* the cursor before the first character of the wrong field.
*/
fun List<EditorBlock>.focusedAtEnd(): List<EditorBlock> {
if (isEmpty()) return this
val last = last()
return dropLast(1) + last.copy(value = last.value.copy(selection = TextRange(last.value.text.length)))
}
@@ -0,0 +1,144 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.ChecklistItem
import com.fabledsword.thoughtsync.core.Note
/**
* The checklist, with real checkboxes this time.
*
* The card renders glyphs because it is a preview; here every row is live. This is
* the other half of the answer to how a list gets typed on a phone: the capture
* sheet takes a whole list at once, one item per line, because at capture time the
* list is already in your head and a tap per row would be the slow part. The
* editor is where a list is REVISED, and revising is item-at-a-time — so this is
* where the per-row control lives.
*
* No empty state: a checklist with no items already shows the add row with its
* hint, which says the same thing an empty state would and can be typed into.
*/
@Composable
fun ChecklistEditor(
note: Note,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
Column {
note.items.forEach { item ->
ChecklistRow(item = item, readOnly = readOnly, onAction = onAction)
}
if (!readOnly) {
AddItemRow(onAdd = { onAction(EditorAction.AddItem(it)) })
}
}
}
/**
* One row: a live checkbox, editable text, and a remove button.
*
* The text commits on FOCUS LOSS rather than per keystroke. Every commit is a
* store write that reloads the note, so per-keystroke saving would both hammer
* SQLite and race the reload against the next character.
*/
@Composable
private fun ChecklistRow(
item: ChecklistItem,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
// Keyed by item id, so a reload after some OTHER row's edit doesn't reset the
// text being typed here.
var text by remember(item.id) { mutableStateOf(item.text) }
val commit = { if (text != item.text) onAction(EditorAction.SetItemText(item.id, text)) }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { onAction(EditorAction.SetItemChecked(item.id, it)) },
enabled = !readOnly,
)
PlainTextField(
value = text,
onValueChange = { text = it },
modifier =
Modifier
.weight(1f)
.onFocusChanged { if (!it.isFocused) commit() },
enabled = !readOnly,
singleLine = true,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
// Struck through when done, matching the card and the web.
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { commit() }),
)
if (!readOnly) {
IconButton(onClick = { onAction(EditorAction.DeleteItem(item.id)) }) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_item),
)
}
}
}
}
/**
* The always-present row at the bottom for adding an item.
*
* It clears but keeps focus after a submit, so a list can be typed straight
* through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That
* is the same speed the capture sheet's one-item-per-line field buys, carried into
* the editor so refining a list never feels slower than making one.
*/
@Composable
private fun AddItemRow(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.padding(horizontal = 12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
PlainTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
hint = R.string.editor_add_item,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAdd(text)
text = ""
}),
)
}
}
@@ -1,6 +1,5 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import android.text.format.DateUtils
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -9,31 +8,23 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -48,19 +39,7 @@ import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.Note
/** /**
* The editor's action bar, along the top of the surface. * The editor's action bar, at the bottom where a thumb already is.
*
* It sits exactly where the capture sheet's drag handle used to. The handle cost
* this strip of screen and did nothing that a back gesture does not already do, so
* the strip carries the actions instead.
*
* Top rather than bottom, now that this one surface is used for WRITING as well as
* editing: the keyboard owns the bottom of the display for most of a note's life,
* so a bar down there spends its time riding on the IME. That is the right place
* for a send button and the wrong one for a colour picker, which is reached for
* between thoughts rather than at the end of them. The cost is honest — the top of
* a phone is further from a thumb than the bottom — and it buys a bar that does not
* move while you type.
* *
* The three affordances with a permanent slot are the ones reached for while still * The three affordances with a permanent slot are the ones reached for while still
* writing — colour, reminder, note-or-list. Everything structural (pin, labels, * writing — colour, reminder, note-or-list. Everything structural (pin, labels,
@@ -75,31 +54,16 @@ import com.fabledsword.thoughtsync.core.Note
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EditorTopBar( fun EditorBottomBar(
note: Note, note: Note,
readOnly: Boolean, readOnly: Boolean,
tint: NoteTint, tint: NoteTint,
onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit, onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
TopAppBar( BottomAppBar(containerColor = tint.background(dark)) {
title = {},
navigationIcon = {
// The only way out, and the only thing that needed a "save" button
// before writes became continuous. Leaving IS saving now, which is what
// the line in the bottom corner is there to say out loud.
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
},
actions = {
if (!readOnly) { if (!readOnly) {
// A dot in the note's CURRENT colour rather than a palette icon: it // A dot in the note's CURRENT colour rather than a palette icon: it
// shows what the colour is as well as what the button does. // shows what the colour is as well as what the button does.
@@ -119,17 +83,21 @@ fun EditorTopBar(
contentDescription = stringResource(R.string.editor_reminder), contentDescription = stringResource(R.string.editor_reminder),
) )
} }
// Inserts `- [ ] ` at the caret. Always available, and never hidden: // Adds the first checklist item, which is what makes the checklist
// a checklist is text now (M304), so there is no section to be // editor appear. Hidden once the note already has one — there is nothing
// already-showing and no reason a second list cannot start further // left to add that the checklist's own "+" row doesn't do better.
// down the same note. if (note.items.isEmpty()) {
IconButton(onClick = onStartChecklist) { IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
Icon( Icon(
Icons.AutoMirrored.Filled.List, Icons.AutoMirrored.Filled.List,
contentDescription = stringResource(R.string.editor_add_checklist), contentDescription = stringResource(R.string.editor_add_checklist),
) )
} }
} }
}
Box(modifier = Modifier.weight(1f))
OverflowMenu( OverflowMenu(
note = note, note = note,
readOnly = readOnly, readOnly = readOnly,
@@ -137,127 +105,6 @@ fun EditorTopBar(
onConfirmDelete = onConfirmDelete, onConfirmDelete = onConfirmDelete,
onAction = onAction, onAction = onAction,
) )
},
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
// returns Unspecified for anything else. A note tint is never a role, so the
// icons drew with no colour filter: black vectors on a near-black bar, a
// toolbar that rendered the whole time and was invisible in dark mode.
//
// onSurface for the actions too, not the default onSurfaceVariant: these sit
// on a tinted bar rather than a scheme surface, and the muted variant does
// not have the contrast to spare.
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = tint.background(dark),
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
)
}
/**
* The footer: when the note was last written, and the way out.
*
* **Where the note stands.** There is no save button, and there should not be — a
* note is saved continuously, so a button offering to do what already happened is a
* lie with a tap attached. But that left nothing on screen saying the work is safe,
* and "closing this keeps it" is not a thing anyone should have to be told twice. So
* the state says it, as a fact rather than an instruction: Not saved yet → Saving… →
* Edited just now is the whole lifecycle, and someone who watches it once never has
* to wonder again.
*
* **The way out.** Down here because of where hands are. Moving the toolbar to the
* top took the back arrow with it, which left the only exit from a full-screen
* editor in the top-left corner — the furthest point on the display from a
* right-handed thumb, and reached over the whole note to get to. The operator hit
* that on the first device pass and was right to. So the exit lives in the bottom
* corner, which with the keyboard up sits directly above it.
*
* The top-left arrow stays as well. Two affordances for one action is usually
* clutter, but this is the case that earns it: the arrow is what habit, the system
* back gesture and TalkBack all expect of a full-screen surface, and removing it
* would strand the reflex to strike a duplicate that costs one icon slot.
*
* A checkmark, at the operator's ask. I had shipped the word "Done" here on the
* argument that a tick in a NOTES app reads as a checklist item; overruled, and the
* filled treatment is what settles it — a tonal button in the note's own colour is
* plainly a control, where a bare glyph beside a checklist would not be. It carries
* "Done" as its content description, so the reasoning survives where it actually
* mattered: read aloud.
*
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already
* knows the difference between minutes, hours and yesterday, and getting plurals
* right in every language is not this app's problem to solve twice.
*/
@Composable
fun EditorFooter(
updatedAt: String?,
saving: Boolean,
tint: NoteTint,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
Row(
modifier =
modifier
.fillMaxWidth()
// Rides above the keyboard, like the bar that used to be here. The
// content Column deliberately does not also inset for the IME:
// Scaffold measures this row at its lifted height and passes the
// inset down.
.imePadding()
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = 4.dp),
// The gap is what keeps the timestamp from reading as the button's label.
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = savedLabel(updatedAt, saving),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FilledTonalIconButton(
onClick = onClose,
// The note's own colour rather than the scheme's secondaryContainer,
// which would be the one element on a tinted card ignoring the tint.
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = tint.chipBackground(dark),
contentColor = tint.chipForeground(dark),
),
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
}
}
}
/** The three things the footer can be saying, in the order it says them. */
@Composable
private fun savedLabel(
updatedAt: String?,
saving: Boolean,
): String {
// No timestamp means no row yet — a draft opened by + and not typed into.
val at = updatedAt?.let { epochMillis(it) }
val now = System.currentTimeMillis()
return when {
saving -> stringResource(R.string.editor_saving)
at == null -> stringResource(R.string.editor_unsaved)
// DateUtils rounds anything under its minimum resolution to "0 minutes
// ago" — which is both odd-looking and precisely the moment this line is
// on screen for, since it is the moment right after a save lands.
now - at < DateUtils.MINUTE_IN_MILLIS ->
stringResource(R.string.editor_edited, stringResource(R.string.editor_just_now))
else ->
stringResource(
R.string.editor_edited,
DateUtils.getRelativeTimeSpanString(at, now, DateUtils.MINUTE_IN_MILLIS).toString(),
)
} }
} }
@@ -15,26 +15,24 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
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.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
import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.BodyItem import com.fabledsword.thoughtsync.core.ChecklistItem
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel import com.fabledsword.thoughtsync.core.NoteLabel
import com.fabledsword.thoughtsync.core.checklistItems
@Composable @Composable
fun NoteCard( fun NoteCard(
note: Note, note: Note,
onOpen: () -> Unit, onOpen: () -> Unit,
onToggleItem: (Int, Boolean) -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val tint = noteTint(note.color) val tint = noteTint(note.color)
@@ -55,12 +53,21 @@ fun NoteCard(
// nothing above them: the first line of the body IS the note's name, at the // nothing above them: the first line of the body IS the note's name, at the
// same weight as the rest of it (M13 steps 3 and 4). // same weight as the rest of it (M13 steps 3 and 4).
if (note.body.isNotBlank()) { if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem) Text(
text = note.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_PREVIEW_LINES,
overflow = TextOverflow.Ellipsis,
)
}
if (note.items.isNotEmpty()) {
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
Checklist(items = note.items)
} }
// A note with nothing in it still has to occupy the board legibly — otherwise // A note with no body and no items still has to occupy the board legibly —
// it reads as a rendering bug. // otherwise it reads as a rendering bug.
if (note.body.isBlank()) { 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,
@@ -81,83 +88,18 @@ fun NoteCard(
} }
} }
/**
* The note's body, with its checklist drawn where it actually sits.
*
* Rendered line by line rather than as one block of text, because an item is a line
* of the body now (M304) and a card that showed the prose and then the list would put
* every list in the wrong place — and, since the body already contains those lines,
* would show each one twice.
*
* Which lines are items is asked of the core rather than matched here. The grammar is
* already written three times; a fourth in Compose would be a fourth place for a
* checklist to change shape when it syncs.
*/
@Composable @Composable
private fun NoteBody( private fun Checklist(items: List<ChecklistItem>) {
note: Note,
onToggleItem: (Int, Boolean) -> Unit,
) {
val lines = remember(note.body) { note.body.split("\n") }
// Read from the BODY rather than from note.items, which is the same list by a
// longer route — and one that can lag the text by a save.
val itemAtLine =
remember(note.body) {
checklistItems(note.body)
.mapIndexed { index, item -> item.line.toInt() to (index to item) }
.toMap()
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line -> items.take(MAX_CHECKLIST_ROWS).forEach { item ->
val found = itemAtLine[n]
when {
found != null ->
ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) }
// Kept as a gap rather than dropped: it is the paragraph break
// somebody typed, and the card reads as a wall without it.
line.isBlank() -> Spacer(Modifier.height(4.dp))
else ->
Text(
text = line,
style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_WRAPPED_LINES,
overflow = TextOverflow.Ellipsis,
)
}
}
if (lines.size > MAX_PREVIEW_LINES) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* One checklist row on a card, with a box you can actually tick.
*
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
* space a list out like a form. The tap target is the glyph's own padding, which is
* why it carries `clickable` rather than the row — clicking the TEXT should open the
* note, the way clicking anywhere else on the card does.
*/
@Composable
private fun ChecklistRow(
item: BodyItem,
onToggle: () -> Unit,
) {
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.Top) {
// A glyph rather than a real Checkbox: the card is a PREVIEW, and
// a live control here would invite taps that the board cannot yet
// honour. It becomes interactive with the editor.
Text( Text(
text = if (item.checked) "" else "", text = if (item.checked) "" else "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = modifier = Modifier.padding(end = 6.dp),
Modifier
.clickable(onClick = onToggle)
.padding(end = 6.dp),
) )
Text( Text(
text = item.text, text = item.text,
@@ -169,10 +111,21 @@ private fun ChecklistRow(
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
}, },
maxLines = MAX_WRAPPED_LINES, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
}
val hidden = items.size - MAX_CHECKLIST_ROWS
if (hidden > 0) {
Text(
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
} }
@Composable @Composable
@@ -229,9 +182,7 @@ private fun ReminderChip(
} }
private const val MAX_PREVIEW_LINES = 8 private const val MAX_PREVIEW_LINES = 8
private const val MAX_CHECKLIST_ROWS = 8
/** How far one long line of a card may wrap before it is cut. */
private const val MAX_WRAPPED_LINES = 2
private const val MAX_LABEL_CHIPS = 3 private const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp private val CARD_RADIUS = 12.dp
private val CHIP_RADIUS = 6.dp private val CHIP_RADIUS = 6.dp
@@ -1,30 +1,32 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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
@@ -32,31 +34,23 @@ 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
import com.fabledsword.thoughtsync.core.Note import com.fabledsword.thoughtsync.core.Note
import kotlinx.coroutines.delay
/** /**
* The one writing surface: a new note and an existing one are the same screen. * The note editor: a full screen, not a sheet.
* *
* Shaped like the capture sheet it replaced — a rounded card that begins below the * A sheet works for capture, where the board behind it is reassurance that the
* status bar — so opening a note still reads as something rising over the board * thought landed somewhere. Editing is different — a sustained task with the
* rather than a place you navigated to. It is full height rather than a real * keyboard up — and a sheet would spend the whole time fighting the IME for the
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session * bottom half of the display. Full screen also gives the actions a bottom bar,
* negotiating with the IME for the bottom half of the display, and the swipe-down it * which is where a thumb already is.
* buys is a gesture back already does. The shape is what was worth keeping.
* *
* The note's own colour paints the WHOLE card rather than a panel inside it, so * The note's own colour paints the WHOLE screen rather than a card inside it, so
* opening a note reads as the same object growing to fill the display. * opening a note reads as the same object growing to fill the display.
*
* No save button, deliberately. Writes are continuous, so a button offering to do
* what already happened would be a lie with a tap attached; [EditorFooter] in the
* bottom corner says the same thing as a fact instead, beside the Done that
* leaves.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NoteEditorScreen( fun NoteEditorScreen(
note: Note, note: Note,
sessionKey: Long,
labels: List<Label>, labels: List<Label>,
saving: Boolean, saving: Boolean,
error: String?, error: String?,
@@ -65,30 +59,11 @@ fun NoteEditorScreen(
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val tint = noteTint(note.color) val tint = noteTint(note.color)
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it // Keyed by note id: the editor is reused across notes, and without the key the
// needs a key — but a draft's id changes the moment it is first saved, and // second note opened would show the first one's text.
// re-keying on that would reset this state to whatever the store just returned, var body by remember(note.id) { mutableStateOf(note.body) }
// throwing away every character typed during the write. var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
// var confirmingDelete by remember(note.id) { mutableStateOf(false) }
// BLOCKS rather than one string, because a checklist item is drawn as a real
// checkbox now and a widget cannot live inside a text field. The note is still one
// markdown body underneath — see EditorBlock.kt — and `bodyText` is what is saved.
//
// Saveable, because a new note has nothing to fall back on if the phone rotates
// mid-capture. The saver carries the TEXT and re-derives the shape, since a block's
// id means nothing across a process death.
var blocks by
rememberSaveable(sessionKey, stateSaver = blocksSaver) {
mutableStateOf(splitBlocks(note.body).focusedAtEnd())
}
// Which field the caret is wanted in, or null. Held HERE rather than inside
// BlockBody because the toolbar's checklist button also asks for one.
var focus by remember(sessionKey) { mutableStateOf<Long?>(null) }
val bodyText = remember(blocks) { joinBlocks(blocks) }
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
// A note in the trash is a record, not a document: editing one would silently // A note in the trash is a record, not a document: editing one would silently
// resurrect work that was meant to be thrown away. It renders read-only, with // resurrect work that was meant to be thrown away. It renders read-only, with
@@ -100,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 && bodyText != note.body) { if (!readOnly && body != note.body) {
onAction(EditorAction.SaveText(bodyText)) onAction(EditorAction.SaveText(body))
} }
} }
val leave = { val leave = {
@@ -109,32 +84,6 @@ fun NoteEditorScreen(
onAction(EditorAction.Close) onAction(EditorAction.Close)
} }
// Opening an existing note means continuing it. Without this the note arrives
// unfocused, and carrying on costs a tap into the last field.
//
// Not for a trashed note: it renders read-only, and a keyboard over a record you
// cannot edit is noise.
LaunchedEffect(sessionKey) {
if (!readOnly) focus = blocks.lastOrNull()?.id
}
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
// keystroke, so the delay only ever elapses once typing stops.
//
// Saving this often is affordable because a body write no longer costs a
// revision: history snapshots once per editing session rather than once per
// save. Before that, writing was expensive enough that this editor hoarded
// text until it closed — and an app kill mid-session lost the lot.
//
// For a note that does not exist yet this is also what CREATES it, which is why
// every toolbar button works moments after the first keystroke rather than
// needing the note to be saved by hand first.
LaunchedEffect(bodyText, sessionKey) {
if (readOnly || bodyText == note.body) return@LaunchedEffect
delay(AUTOSAVE_IDLE_MS)
onAction(EditorAction.SaveText(bodyText))
}
BackHandler(onBack = leave) BackHandler(onBack = leave)
// Leaving the APP is not closing the editor, so the text has to be saved // Leaving the APP is not closing the editor, so the text has to be saved
@@ -142,101 +91,71 @@ fun NoteEditorScreen(
// is exactly the failure that makes someone stop trusting a notes app. // is exactly the failure that makes someone stop trusting a notes app.
FlushOnStop(flush) FlushOnStop(flush)
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
// status bar AND consumes that inset, so the bar inside adds no second gap of
// its own — the strip above the rounded corner is what makes this read as a card
// over the board rather than a screen that replaced it.
Box(
modifier =
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars),
) {
Surface(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
color = tint.background(dark),
// Both content colours are spelled out for the reason the toolbar had to
// be: Surface and Scaffold each default theirs to contentColorFor(their
// container), which returns Unspecified for anything that is not a
// colour-SCHEME ROLE. A note tint never is, so the default publishes
// Unspecified as LocalContentColor and everything inside that does not
// set its own colour draws black — which is how the last toolbar became
// invisible in dark mode.
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Scaffold( Scaffold(
containerColor = tint.background(dark), containerColor = tint.background(dark),
contentColor = MaterialTheme.colorScheme.onSurface,
topBar = { topBar = {
EditorTopBar( TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = leave) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)),
)
},
bottomBar = {
EditorBottomBar(
note = note, note = note,
readOnly = readOnly, readOnly = readOnly,
tint = tint, tint = tint,
onClose = leave,
onStartChecklist = {
val (next, id) = blocks.plusTask()
blocks = next
focus = id
},
onPicker = { picker = it }, onPicker = { picker = it },
onConfirmDelete = { confirmingDelete = true }, onConfirmDelete = { confirmingDelete = true },
onAction = onAction, onAction = onAction,
) )
}, },
// Where the action bar used to be, carrying the two things that
// belong within reach of a thumb: whether the note is safe, and the
// way out. See [EditorFooter] for why the exit is down here and not
// only in the top-left corner.
bottomBar = {
EditorFooter(
updatedAt = note.updatedAt,
saving = saving,
tint = tint,
onClose = leave,
)
},
) { padding -> ) { padding ->
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
// No imePadding here: EditorFooter carries it, so
// Scaffold measures that row at its keyboard-lifted
// height and the inset already reaches this Column
// through `padding`. Adding it again would inset for the
// keyboard twice.
.padding(padding) .padding(padding)
.imePadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
// A failed save has to be visible HERE. The board renders the // A one-pixel line, not a spinner: a save slow enough to see is worth
// same banner, but a write that fails while the editor is open // showing, and one that isn't must not make the screen jump.
// would otherwise report itself only after the user had already if (saving) {
// left. LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
error?.let { message ->
ErrorBanner(
message = message,
onDismiss = { onAction(EditorAction.DismissError) },
)
} }
// A note is its body; its NAME is that body's first line, so there // A failed save has to be visible HERE. The board renders the same
// is nothing separate to type into and nothing rendered bolder than // banner, but a write that fails while the editor is open would
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only // otherwise report itself only after the user had already left.
// how the body is DRAWN — checklist items as boxes rather than as error?.let { message ->
// the markup for boxes. ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
BlockBody( }
blocks = blocks,
readOnly = readOnly, // One field. A note is its body; its NAME is that body's first line, so
focus = focus, // there is nothing separate to type into and nothing to render bolder
onChange = { blocks = it }, // than the line beneath it (M13 steps 3 and 4).
onFocus = { focus = it }, EditorField(
value = body,
onValueChange = { body = it },
hint = R.string.editor_body_hint,
enabled = !readOnly,
minLines = MIN_BODY_LINES,
) )
// No checklist section. The items ARE lines of the field above // Below the body, not instead of it, and only once the note has items —
// (M304) — rendering them again down here is what would put every // the toolbar's add-checklist action is what puts the first one there.
// list on screen twice. if (note.items.isNotEmpty()) {
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
}
if (note.labels.isNotEmpty()) { if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction) EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
@@ -252,8 +171,6 @@ fun NoteEditorScreen(
} }
} }
} }
}
}
EditorOverlays( EditorOverlays(
note = note, note = note,
@@ -328,16 +245,32 @@ private fun EditorOverlays(
} }
/** /**
* How long typing has to stop before the note is written. * The note's body field.
* *
* Long enough that a normal sentence is one write, short enough that nothing * Undecorated, via the shared [PlainTextField]: the screen is already painted in
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still * the note's colour, and a filled field would draw a second surface over the first
* cover the window between the last keystroke and this elapsing. * 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.
*/ */
private const val AUTOSAVE_IDLE_MS = 1_000L @Composable
private fun EditorField(
value: String,
onValueChange: (String) -> Unit,
@StringRes hint: Int,
enabled: Boolean,
minLines: Int = 1,
) {
PlainTextField(
value = value,
onValueChange = onValueChange,
hint = hint,
enabled = enabled,
minLines = minLines,
textStyle = MaterialTheme.typography.bodyLarge,
)
}
/** private const val MIN_BODY_LINES = 6
* The card's top corner radius — Material's extra-large, which is what a bottom
* sheet uses. Same shape as the capture surface this replaced, on purpose.
*/
private val SHEET_CORNER = 28.dp
@@ -9,7 +9,6 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -21,16 +20,12 @@ import androidx.compose.ui.text.input.VisualTransformation
/** /**
* A text field with no box around it. * A text field with no box around it.
* *
* The search box, the label picker, the sync-pairing form: fields that sit on a * Every writing surface in the app — the capture sheet, the editor's title and
* surface which already has its own edges and its own colour, where Material's filled * body, each checklist row — sits on a surface that already has its own edges and
* field would draw a second, differently coloured box inside the first. Stripping the * its own colour. Material's filled field would draw a second, differently
* container and the indicator at each site independently is how they drift apart, so * coloured box inside the first, which makes writing a note look like filling in a
* it happens once, here. * form. Stripping the container and the indicator in four places independently is
* * how they drift apart, so it happens once, here.
* The note EDITOR no longer comes through this. It dropped to `BasicTextField`
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its
* text, which is right for a form and is the whole row height on a checklist. Nothing
* about "no box" was lost there — BasicTextField never had one.
* *
* The disabled colours are stripped too: a trashed note is shown through this * The disabled colours are stripped too: a trashed note is shown through this
* field read-only, and Material's disabled treatment would grey out text the user * field read-only, and Material's disabled treatment would grey out text the user
@@ -63,21 +58,11 @@ fun PlainTextField(
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
colors = plainFieldColors(), colors =
)
}
/**
* One definition of "no box". Two copies of this is exactly the drift this file
* exists to prevent.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun plainFieldColors(): TextFieldColors =
TextFieldDefaults.colors( TextFieldDefaults.colors(
// Full-strength, not Material's 38%-alpha disabled treatment: a trashed // Full-strength, not Material's 38%-alpha disabled treatment: a
// note is rendered read-only through this field and its text is meant to // trashed note is rendered read-only through this field and its
// be READ, not visually retired. // text is meant to be READ, not visually retired.
disabledTextColor = MaterialTheme.colorScheme.onSurface, disabledTextColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
@@ -85,4 +70,6 @@ private fun plainFieldColors(): TextFieldColors =
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent,
),
) )
}
@@ -1,5 +1,6 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -66,14 +67,9 @@ fun reminderLabel(
} }
} }
/** A stored instant as epoch milliseconds, or null if it will not parse. */
fun epochMillis(raw: String): Long? = runCatching { OffsetDateTime.parse(raw).toInstant().toEpochMilli() }.getOrNull()
/** Whether a stored reminder has already passed, for showing it as overdue. */ /** Whether a stored reminder has already passed, for showing it as overdue. */
fun isPast(raw: String): Boolean { fun isPast(raw: String): Boolean =
val at = epochMillis(raw) ?: return false runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
return at < System.currentTimeMillis()
}
/** /**
* Whether a timestamp is older than [minutes] ago — or absent entirely. * Whether a timestamp is older than [minutes] ago — or absent entirely.
@@ -87,8 +83,8 @@ fun olderThan(
raw: String?, raw: String?,
minutes: Long, minutes: Long,
): Boolean { ): Boolean {
val at = raw?.let { epochMillis(it) } val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() }
return at == null || at < System.currentTimeMillis() - minutes * MILLIS_PER_MINUTE return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE)
} }
private const val MILLIS_PER_MINUTE = 60_000L private const val SECONDS_PER_MINUTE = 60L
+8 -6
View File
@@ -10,9 +10,16 @@
<!-- Compose sheet --> <!-- Compose sheet -->
<string name="compose_open">New note</string> <string name="compose_open">New note</string>
<string name="compose_body_hint">Take a note…</string>
<string name="compose_discard">Discard</string>
<string name="compose_save">Save</string>
<!-- Board --> <!-- Board -->
<string name="board_empty_note">Empty note</string> <string name="board_empty_note">Empty note</string>
<plurals name="board_more_items">
<item quantity="one">+%d more item</item>
<item quantity="other">+%d more items</item>
</plurals>
<!-- Empty states. Each destination says something true of ITSELF; a single <!-- Empty states. Each destination says something true of ITSELF; a single
"nothing here" reads as encouragement on the board and as a fault in Trash. --> "nothing here" reads as encouragement on the board and as a fault in Trash. -->
@@ -31,17 +38,12 @@
<string name="board_open_note">Open note</string> <string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string> <string name="editor_back">Back to notes</string>
<string name="editor_add_checklist">Add a checklist</string> <string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Take a note</string> <string name="editor_body_hint">Note</string>
<string name="editor_add_item">Add item</string> <string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string> <string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string> <string name="editor_remove_label">Remove label</string>
<string name="editor_reminder">Set a reminder</string> <string name="editor_reminder">Set a reminder</string>
<string name="editor_more">More actions</string> <string name="editor_more">More actions</string>
<string name="editor_saving">Saving…</string>
<string name="editor_unsaved">Not saved yet</string>
<string name="editor_edited">Edited %1$s</string>
<string name="editor_just_now">just now</string>
<string name="editor_done">Done</string>
<string name="editor_pin">Pin</string> <string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string> <string name="editor_unpin">Unpin</string>
<string name="editor_labels">Labels…</string> <string name="editor_labels">Labels…</string>
+4 -36
View File
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state}; use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{ use models::{
patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus, RevokeOutcome, SyncOutcome, SyncStatus,
}; };
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
@@ -499,37 +499,6 @@ impl ThoughtSync {
} }
} }
// ── checklist text, as pure functions ───────────────────────────────────────
//
// The pair the block editor is built on: one to read a body apart, one to put a line
// back together. Between them, Kotlin can render a checklist as real checkboxes and
// write the markdown back without owning the grammar — which is the point. Three
// implementations of it is the price already being paid (Rust, Python, TypeScript);
// a fourth in Compose would be one more place for a checklist to change shape when
// it syncs.
//
// Free functions rather than methods, because they touch no database. The editor's
// body is LOCAL state — autosaved on an idle debounce, not written per keystroke —
// so editing a checklist there has to rewrite the text the editor is holding, not a
// row the store would hand back a moment later and overwrite the typing with.
/// One checklist item as the body line that stores it. For an editor that shows a
/// checkbox instead of the markup and has to write the markup back.
#[uniffi::export]
pub fn checklist_render(text: String, checked: bool) -> String {
local::derive::render_item(&text, checked)
}
/// Every checklist item in a body, with the line each one sits on — so a renderer
/// walking the body line by line knows which lines are boxes and what is in them.
#[uniffi::export]
pub fn checklist_items(body: String) -> Vec<BodyItem> {
local::derive::extract_items(&body)
.into_iter()
.map(BodyItem::from)
.collect()
}
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]` /// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side. /// block names, so these stay Rust-side.
impl ThoughtSync { impl ThoughtSync {
@@ -713,9 +682,8 @@ mod tests {
assert!(ticked.items[1].checked); assert!(ticked.items[1].checked);
assert_eq!( assert_eq!(
ticked.items[1].text, "charger", ticked.items[1].text, "charger",
"ticking a box must not disturb its text — both setters rewrite the \ "ticking a box must not disturb its text — the two setters write \
same line of the body now, so one clobbering the other is a live risk \ different columns and neither may clear the other"
rather than a theoretical one"
); );
let renamed = app let renamed = app
-27
View File
@@ -49,33 +49,6 @@ pub struct Note {
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
/// A checklist item as it sits in a note's body.
///
/// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks
/// a body line by line needs the text, the state and the position together — the card
/// to draw a box in the right place, the block editor to know where one block ends.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyItem {
pub line: u32,
pub text: String,
pub checked: bool,
}
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
let thoughtsync_core::local::derive::DerivedItem {
text,
checked,
line,
} = i;
BodyItem {
line,
text,
checked,
}
}
}
/// An Android build the linked server is offering, already judged to be newer. /// An Android build the linked server is offering, already judged to be newer.
/// ///
/// A mirror rather than a re-export of `client::ClientRelease`, for the same /// A mirror rather than a re-export of `client::ClientRelease`, for the same
+3 -406
View File
@@ -1,18 +1,9 @@
//! Deriving structure from a note's body — the local mirror of what the server //! Deriving `#tags` from a note's body — the local mirror of what the server computes
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep //! on save. Pure string scanning (no regex dependency), kept in lockstep with the
//! with the frontend's inline rules (see frontend notes/markdown.ts): //! frontend's inline rules (see frontend notes/markdown.ts):
//! //!
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first). //! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
//! On save these become labels attached with `via_tag = true`. //! On save these become labels attached with `via_tag = true`.
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
//! table of items beside it, so a list can sit between two paragraphs instead of
//! only after them.
//!
//! The two are the same idea at different strengths. Tags MATERIALISE into label
//! rows, because the board queries by label. Items materialise into nothing,
//! because nothing queries them: their only readers are the card, the editor and
//! `display_title`. So `extract_items` is the whole storage layer for a checklist,
//! and the rewriters below are how one is edited.
//! //!
//! Dedupes case-insensitively, preserving first-seen order. //! Dedupes case-insensitively, preserving first-seen order.
//! //!
@@ -54,239 +45,6 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
} }
} }
// ── checklist items ─────────────────────────────────────────────────────────
//
// The grammar, in one place, because three languages implement it (here,
// `notes/checklist.py`, `notes/markdown.ts`) and a difference between any two of
// them is a checklist that changes shape when it syncs:
//
// optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`,
// then either end-of-line or one-or-more spaces and the text.
//
// `*` is accepted because markdown.ts already accepts it for a plain bullet, and a
// grammar that takes `* item` but not `* [ ] item` would be a rule with no reason
// anyone could guess. `- [ ]` with nothing after it IS an item with empty text:
// that is exactly what pressing Enter on a list leaves behind, and refusing to
// parse it would make a half-typed list stop being a list.
/// A checklist item, as found in the body. Its position in the returned vector is
/// its identity — the same thing `position` meant when these were rows, and all the
/// wire ever carried (`push.rs` sent text and checked, never an id).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedItem {
pub text: String,
pub checked: bool,
/// Which body line it sits on.
///
/// Carried here rather than offered as a second function, because every renderer
/// that walks a body line by line — the Android card, the block editor — needs the
/// text, the state AND the position together, and asking for them separately is
/// how two calls come to disagree about a body that changed between them.
pub line: u32,
}
/// One parsed task line, holding enough to put it back exactly as it was found.
struct TaskLine<'a> {
indent: &'a str,
/// Preserved rather than normalised to `-`: rewriting someone's `*` bullets
/// because they ticked a box would be an edit they did not ask for.
bullet: char,
checked: bool,
text: &'a str,
}
fn parse_task_line(line: &str) -> Option<TaskLine<'_>> {
let indent_len = line.len() - line.trim_start().len();
let (indent, rest) = line.split_at(indent_len);
let bullet = rest.chars().next()?;
if bullet != '-' && bullet != '*' {
return None;
}
// At least one space after the bullet. `-[ ] x` is not a list item in any
// markdown either, so it stays prose here too.
let rest = &rest[bullet.len_utf8()..];
let gap = rest.len() - rest.trim_start_matches(' ').len();
if gap == 0 {
return None;
}
let rest = &rest[gap..];
let mut chars = rest.chars();
if chars.next()? != '[' {
return None;
}
let mark = chars.next()?;
if chars.next()? != ']' {
return None;
}
// Decided BEFORE the slice below, which is what guarantees `mark` is one byte
// and `[?]` is exactly three.
let checked = match mark {
' ' => false,
'x' | 'X' => true,
_ => return None,
};
let rest = &rest[3..];
let text = if rest.is_empty() {
// "- [ ]" — an empty item, which is what an unfinished list line is.
rest
} else {
let gap = rest.len() - rest.trim_start_matches(' ').len();
// "- [ ]x" is prose: without the space this is not a marker, it is a
// sentence that happens to start with brackets.
if gap == 0 {
return None;
}
&rest[gap..]
};
Some(TaskLine {
indent,
bullet,
checked,
text,
})
}
/// One item as the line that stores it, in canonical form.
///
/// Public because a block editor has to write a line back after someone edits it in a
/// widget that never showed them the marker. Rendering is trivial where PARSING is
/// not, but it still belongs here: this is the file that decides what canonical looks
/// like, and a caller inventing its own `- [x] ` would be a fourth opinion on it.
pub fn render_item(text: &str, checked: bool) -> String {
render_task_line("", '-', checked, text)
}
fn render_task_line(indent: &str, bullet: char, checked: bool, text: &str) -> String {
// Always lowercase `x`, whatever was parsed: one canonical output is what makes
// a round trip stable, so `- [X]` normalises the first time it is touched and
// never again.
let mark = if checked { 'x' } else { ' ' };
if text.is_empty() {
format!("{indent}{bullet} [{mark}]")
} else {
format!("{indent}{bullet} [{mark}] {text}")
}
}
/// The text of a line with its task marker removed, or the line as it was.
///
/// For naming a note: a list-only note is named by its first item, and calling one
/// "- [ ] milk" would be showing someone the storage instead of the note.
pub fn strip_marker(line: &str) -> &str {
match parse_task_line(line) {
Some(t) => t.text,
None => line,
}
}
/// Every checklist item in `body`, in the order they appear.
pub fn extract_items(body: &str) -> Vec<DerivedItem> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
if let Some(t) = parse_task_line(line) {
out.push(DerivedItem {
text: t.text.to_string(),
checked: t.checked,
line: n as u32,
});
}
}
out
}
/// Rewrite the `index`-th task line, or drop it when `f` returns None.
///
/// A body with fewer task lines than that is returned UNCHANGED rather than
/// panicking: the index comes from a UI that may be a moment behind the store, and
/// a stale tap should do nothing rather than take the app down.
fn map_task_line<F>(body: &str, index: usize, f: F) -> String
where
F: FnOnce(&TaskLine<'_>) -> Option<String>,
{
let lines: Vec<&str> = body.split('\n').collect();
let mut target: Option<usize> = None;
let mut seen = 0usize;
for (n, line) in lines.iter().enumerate() {
if parse_task_line(line).is_some() {
if seen == index {
target = Some(n);
break;
}
seen += 1;
}
}
let target = match target {
Some(n) => n,
None => return body.to_string(),
};
let replacement = match parse_task_line(lines[target]) {
Some(parsed) => f(&parsed),
None => return body.to_string(),
};
let mut out: Vec<String> = Vec::with_capacity(lines.len());
for (n, line) in lines.iter().enumerate() {
if n != target {
out.push((*line).to_string());
} else if let Some(new_line) = &replacement {
out.push(new_line.clone());
}
// None at the target line drops it, which is `remove_item`.
}
out.join("\n")
}
/// Tick or untick the `index`-th item.
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
map_task_line(body, index, |t| {
Some(render_task_line(t.indent, t.bullet, checked, t.text))
})
}
/// Replace the text of the `index`-th item, keeping its state and its bullet.
pub fn set_item_text(body: &str, index: usize, text: &str) -> String {
map_task_line(body, index, |t| {
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim()))
})
}
/// Delete the `index`-th item, line and all.
pub fn remove_item(body: &str, index: usize) -> String {
map_task_line(body, index, |_| None)
}
/// Add an item at the end of the body.
///
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
/// between prose and the list, and nothing between consecutive items. That is not
/// cosmetic: the server migration folds existing rows into bodies using the same
/// layout, so an export taken before the migration and one taken after have to
/// agree byte for byte.
///
/// `checked` is a parameter rather than always false because the two migrations that
/// fold existing rows into bodies have to carry the state those rows were in. A new
/// item from the UI passes false.
pub fn append_item(body: &str, text: &str, checked: bool) -> String {
let line = render_task_line("", '-', checked, text.trim());
let trimmed = body.trim_end_matches('\n');
if trimmed.trim().is_empty() {
return line;
}
let follows_a_list = trimmed
.split('\n')
.next_back()
.is_some_and(|l| parse_task_line(l).is_some());
if follows_a_list {
format!("{trimmed}\n{line}")
} else {
format!("{trimmed}\n\n{line}")
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -314,165 +72,4 @@ mod tests {
fn empty_body() { fn empty_body() {
assert!(extract_tags("").is_empty()); assert!(extract_tags("").is_empty());
} }
// ── checklist items ─────────────────────────────────────────────────────
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
DerivedItem {
text: text.to_string(),
checked,
line,
}
}
#[test]
fn items_basic() {
let body = "shopping\n\n- [ ] milk\n- [x] eggs";
assert_eq!(
extract_items(body),
vec![item("milk", false, 2), item("eggs", true, 3)]
);
}
#[test]
fn items_may_sit_between_paragraphs() {
// The whole reason the body owns the list: a table of rows could only ever
// render after the prose.
let body = "before\n- [ ] middle\nafter";
assert_eq!(extract_items(body), vec![item("middle", false, 1)]);
}
#[test]
fn items_reject_near_misses() {
// Each of these is prose, and each has been someone's bug report somewhere.
for body in [
"-[ ] no space after the dash",
"- [] empty brackets",
"- [ ]no space after the brackets",
"- [y] not a mark",
"a [ ] mid sentence",
"[ ] no bullet at all",
] {
assert!(extract_items(body).is_empty(), "should be prose: {body}");
}
}
#[test]
fn items_accept_star_bullets_and_indentation() {
// `*` because markdown.ts already takes it for a plain bullet.
let body = "* [ ] star\n - [x] indented";
assert_eq!(
extract_items(body),
vec![item("star", false, 0), item("indented", true, 1)]
);
}
#[test]
fn an_empty_item_is_still_an_item() {
// What pressing Enter on a list leaves behind.
assert_eq!(extract_items("- [ ]"), vec![item("", false, 0)]);
assert_eq!(extract_items("- [ ] "), vec![item("", false, 0)]);
}
#[test]
fn uppercase_x_parses_and_normalises_on_rewrite() {
assert_eq!(extract_items("- [X] done"), vec![item("done", true, 0)]);
// Touching it once canonicalises it, and never again.
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
}
#[test]
fn checking_preserves_indent_bullet_and_text() {
assert_eq!(set_item_checked(" * [ ] milk", 0, true), " * [x] milk");
assert_eq!(set_item_checked("- [x] milk", 0, false), "- [ ] milk");
}
#[test]
fn checking_addresses_items_not_lines() {
let body = "note\n- [ ] a\nprose\n- [ ] b";
assert_eq!(
set_item_checked(body, 1, true),
"note\n- [ ] a\nprose\n- [x] b"
);
}
#[test]
fn set_text_keeps_state() {
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
}
#[test]
fn remove_takes_the_whole_line() {
let body = "keep\n- [ ] drop\n- [ ] stay";
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
}
#[test]
fn append_spaces_like_the_exporter() {
// Prose then a blank line then the list — byte-for-byte what
// import_export.py:_note_markdown writes, which is what the server
// migration will fold existing rows into.
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
// Nothing between consecutive items.
let one = "a note\n\n- [ ] milk";
assert_eq!(
append_item(one, "eggs", false),
format!("{one}\n- [ ] eggs")
);
// A list-only note starts at the first line.
assert_eq!(append_item("", "milk", false), "- [ ] milk");
assert_eq!(append_item("\n\n", "milk", false), "- [ ] milk");
// Carries state, which is what the two migrations need of it.
assert_eq!(append_item("", "done", true), "- [x] done");
}
#[test]
fn strip_marker_names_a_list_only_note() {
assert_eq!(strip_marker("- [x] milk"), "milk");
assert_eq!(strip_marker("just prose"), "just prose");
}
#[test]
fn render_item_is_what_extract_reads_back() {
assert_eq!(render_item("milk", false), "- [ ] milk");
assert_eq!(render_item("done", true), "- [x] done");
// An empty item has no trailing space, so a round trip does not grow it.
assert_eq!(render_item("", false), "- [ ]");
let line = render_item("milk", true);
assert_eq!(extract_items(&line), vec![item("milk", true, 0)]);
}
#[test]
fn items_carry_the_line_they_sit_on() {
let found = extract_items("a\n- [ ] x\nb\n- [x] y");
assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]);
}
#[test]
fn a_stale_index_does_nothing() {
// The index comes from a UI that may be a moment behind the store. A tap
// that arrives late should be inert, not fatal.
let body = "- [ ] only";
assert_eq!(set_item_checked(body, 7, true), body);
assert_eq!(remove_item(body, 7), body);
assert_eq!(set_item_text(body, 7, "x"), body);
}
#[test]
fn a_plain_body_is_returned_byte_identical() {
let body = "just prose\nwith two lines";
assert_eq!(set_item_checked(body, 0, true), body);
assert_eq!(set_item_text(body, 0, "x"), body);
assert_eq!(remove_item(body, 0), body);
}
#[test]
fn round_trip_is_stable() {
let body = "- [ ] a\n- [x] b\n- [ ] c";
let items = extract_items(body);
// Ticking and unticking returns the original bytes.
let touched = set_item_checked(&set_item_checked(body, 0, true), 0, false);
assert_eq!(touched, body);
assert_eq!(extract_items(&touched), items);
}
} }
+1 -189
View File
@@ -6,9 +6,7 @@
//! //!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change. //! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::Connection;
use crate::local::derive;
const SCHEMA_V1: &str = r#" const SCHEMA_V1: &str = r#"
CREATE TABLE notes ( CREATE TABLE notes (
@@ -56,8 +54,6 @@ CREATE TABLE checklist_items (
position INTEGER NOT NULL DEFAULT 0 position INTEGER NOT NULL DEFAULT 0
); );
CREATE INDEX idx_items_note ON checklist_items (note_id); CREATE INDEX idx_items_note ON checklist_items (note_id);
-- Both dropped in v8; kept here so an existing database has something to migrate
-- FROM, exactly as `kind` above is kept for v6.
CREATE TABLE attachments ( CREATE TABLE attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -184,71 +180,6 @@ ALTER TABLE notes DROP COLUMN title;
ALTER TABLE note_revisions DROP COLUMN title; ALTER TABLE note_revisions DROP COLUMN title;
"#; "#;
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk`
// line is the item — so a list can sit between two paragraphs instead of only after
// them, which a side table could never express no matter how it was styled.
//
// Rust rather than a SQL const, for two reasons. The fold has to produce EXACTLY what
// `derive::append_item` produces, and expressing that in SQL would be a second
// implementation of the layout rule. And `group_concat` only gained a guaranteed
// ORDER BY in SQLite 3.44 — a checklist that silently reordered itself during the
// migration would be a poor way to find that out.
//
// `updated_at` and `dirty` are deliberately NOT touched. The server's Alembic
// migration folds the same rows with the same spacing, so both sides land on
// identical bodies and this needs no sync at all; marking every note dirty would
// push a body the server already has, and would do it for every device at once.
fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
// Grouped in one pass — the query is ordered by note, so a change of note_id is
// the group boundary. `rowid` breaks ties, because `position` was only ever
// advisory and two rows sharing one is not a reason to reorder someone's list.
let mut grouped: Vec<(String, Vec<(String, bool)>)> = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT note_id, text, checked FROM checklist_items
ORDER BY note_id ASC, position ASC, rowid ASC",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let note_id: String = row.get(0)?;
let text: String = row.get(1)?;
let checked: bool = row.get(2)?;
match grouped.last_mut() {
Some((id, items)) if *id == note_id => items.push((text, checked)),
_ => grouped.push((note_id, vec![(text, checked)])),
}
}
}
for (note_id, items) in grouped {
let existing: Option<String> = conn
.query_row("SELECT body FROM notes WHERE id = ?1", [&note_id], |r| {
r.get(0)
})
.optional()?;
// An item whose note is already gone has nothing to fold into. The foreign key
// should make this impossible; skipping costs nothing, and failing here would
// leave the only copy of someone's notes half-migrated.
let mut body = match existing {
Some(b) => b,
None => continue,
};
for (text, checked) in items {
body = derive::append_item(&body, &text, checked);
}
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, note_id],
)?;
}
conn.execute_batch(
"DROP INDEX IF EXISTS idx_items_note;
DROP TABLE checklist_items;",
)?;
Ok(())
}
/// 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;")?;
@@ -281,124 +212,5 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V7)?; conn.execute_batch(SCHEMA_V7)?;
conn.execute_batch("PRAGMA user_version = 7;")?; conn.execute_batch("PRAGMA user_version = 7;")?;
} }
if version < 8 {
migrate_v8(conn)?;
conn.execute_batch("PRAGMA user_version = 8;")?;
}
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
/// A database as it stood before M304 — items still in their own table.
fn v7_db() -> Connection {
let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
for batch in [
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
] {
conn.execute_batch(batch).expect("batch");
}
conn.execute_batch("PRAGMA user_version = 7;").expect("v7");
conn
}
fn add_note(conn: &Connection, id: &str, body: &str) {
conn.execute(
"INSERT INTO notes (id, body, created_at, updated_at)
VALUES (?1, ?2, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')",
params![id, body],
)
.expect("note");
}
fn add_item(conn: &Connection, note: &str, text: &str, checked: bool, pos: i64) {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, checked, position)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![format!("{note}-{pos}"), note, text, checked, pos],
)
.expect("item");
}
fn body_of(conn: &Connection, id: &str) -> String {
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
.expect("body")
}
#[test]
fn v8_folds_items_into_the_body() {
let conn = v7_db();
add_note(&conn, "n1", "shopping");
add_item(&conn, "n1", "milk", false, 0);
add_item(&conn, "n1", "eggs", true, 1);
migrate(&conn).expect("migrate");
// Prose, blank line, list — the layout _note_markdown already exports, so an
// export taken before this migration and one taken after agree byte for byte.
assert_eq!(body_of(&conn, "n1"), "shopping\n\n- [ ] milk\n- [x] eggs");
}
#[test]
fn v8_keeps_a_list_only_note_whole() {
let conn = v7_db();
add_note(&conn, "n1", "");
add_item(&conn, "n1", "milk", false, 0);
migrate(&conn).expect("migrate");
assert_eq!(body_of(&conn, "n1"), "- [ ] milk");
}
#[test]
fn v8_leaves_timestamps_alone() {
// The whole reason this needs no sync: the server folds the same rows the same
// way, so both sides already agree. Marking notes dirty would push a body the
// server has, from every device at once.
let conn = v7_db();
add_note(&conn, "n1", "note");
add_item(&conn, "n1", "milk", false, 0);
migrate(&conn).expect("migrate");
let (updated, dirty): (String, i64) = conn
.query_row(
"SELECT updated_at, dirty FROM notes WHERE id = 'n1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("row");
assert_eq!(updated, "2026-01-01T00:00:00.000Z");
assert_eq!(dirty, 1); // as inserted, not raised by the migration
}
#[test]
fn v8_drops_the_table_and_is_idempotent() {
let conn = v7_db();
add_note(&conn, "n1", "note");
migrate(&conn).expect("migrate");
migrate(&conn).expect("again");
let exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='checklist_items'",
[],
|r| r.get(0),
)
.expect("count");
assert_eq!(exists, 0);
}
#[test]
fn a_fresh_database_reaches_v8() {
let conn = Connection::open_in_memory().expect("open");
migrate(&conn).expect("migrate");
let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.expect("version");
assert_eq!(version, 8);
}
}
+67 -126
View File
@@ -9,7 +9,7 @@
use chrono::{DateTime, Duration, SecondsFormat, Utc}; use chrono::{DateTime, Duration, SecondsFormat, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use serde_json::{json, Value}; use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
use crate::local::derive; use crate::local::derive;
@@ -24,25 +24,24 @@ fn new_id() -> String {
Uuid::new_v4().to_string() Uuid::new_v4().to_string()
} }
/// The note's NAME: the first line of its body that says anything. /// The note's NAME: its first non-blank body line, else its first checklist item.
/// ///
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written /// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
/// twice, and they have to agree or a synced note is called different things on either /// twice, and they have to agree or a synced note is called different things on either
/// side of the wire. /// side of the wire.
/// ///
/// It no longer needs the items, because the items ARE lines of the body now (M304). /// Pure, and given the items rather than fetching them: every caller has already
/// What it needs instead is to strip the task marker off: a list-only note is still /// loaded them, so a query here would be a second trip for something already in hand.
/// named by its first item, and calling that note "- [ ] milk" would be showing fn display_title(body: &str, items: &[ChecklistItem]) -> String {
/// someone the storage rather than the note. An empty item is skipped rather than if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
/// naming the note "", which is what a half-typed list would otherwise do. return line.to_string();
fn display_title(body: &str) -> String {
for line in body.lines() {
let text = derive::strip_marker(line.trim()).trim();
if !text.is_empty() {
return text.to_string();
} }
} items
String::new() .iter()
.map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("")
.to_string()
} }
fn escape_like(s: &str) -> String { fn escape_like(s: &str) -> String {
@@ -70,24 +69,19 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
rows.collect() rows.collect()
} }
/// The note's checklist, read out of its body. No query, because there is no table. fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<ChecklistItem>> {
/// let mut stmt = conn.prepare(
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a "SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC",
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and )?;
/// checked and never an id, and both sides replaced the whole list on every sync. It let rows = stmt.query_map([note_id], |r| {
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act Ok(ChecklistItem {
/// on it directly. id: r.get(0)?,
fn items_of(body: &str) -> Vec<ChecklistItem> { text: r.get(1)?,
derive::extract_items(body) checked: r.get(2)?,
.into_iter() position: r.get(3)?,
.enumerate()
.map(|(i, item)| ChecklistItem {
id: i.to_string(),
text: item.text,
checked: item.checked,
position: i as i64,
}) })
.collect() })?;
rows.collect()
} }
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> { fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
@@ -168,10 +162,11 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
}, },
)?; )?;
note.labels = load_labels(conn, id)?; note.labels = load_labels(conn, id)?;
note.items = items_of(&note.body); 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)?;
note.display_title = display_title(&note.body); // After the items, because a body-only-empty note is named by its first one.
note.display_title = display_title(&note.body, &note.items);
Ok(note) Ok(note)
} }
@@ -363,60 +358,21 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
[], [],
|r| r.get(0), |r| r.get(0),
)?; )?;
// Items fold into the body rather than into rows of their own. Callers still hand
// them over separately — the importer has a list, not a blob — but where they end
// up is one place.
let mut body = input.body.clone();
if let Some(items) = &input.items {
for text in items {
body = derive::append_item(&body, text, false);
}
}
conn.execute( conn.execute(
"INSERT INTO notes (id, 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, ?5, 1)", VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, body, input.color, position, ts], params![id, input.body, input.color, position, ts],
)?;
if let Some(items) = &input.items {
for (i, text) in items.iter().enumerate() {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, text, i as i64],
)?; )?;
// The FOLDED body, not the input one: an item can carry a #tag too.
sync_tags(conn, &id, &body)?;
load_note(conn, &id)
}
/// How long one editing session is assumed to last.
///
/// Inside this window a note's body may be written any number of times and only the
/// FIRST write snapshots. That is what makes an idle-debounced autosave affordable:
/// a write costs a write, not a write plus a revision.
const REVISION_WINDOW_MINUTES: i64 = 10;
/// Whether a body change earns a snapshot of the pre-edit body.
///
/// Two conditions. The body must actually differ — re-saving identical text is not a
/// version of anything. And the note must not already carry a revision from this
/// editing session.
///
/// The session rule is what keeps version history worth reading. Because
/// [`snapshot_revision`] stores the body as it was BEFORE the edit, the first write
/// of a session captures the note as you found it, and every write after it inside
/// the window adds nothing. One revision per sitting falls out of the window on its
/// own — no "commit" the client has to declare, and no protocol surface to carry it,
/// which matters because sync-apply takes this same path.
fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Result<bool> {
let current: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
if current == new_body {
return Ok(false);
} }
// String comparison, not date maths: timestamps are RFC3339 UTC with a fixed }
// millisecond field (see the module header), so lexical order IS chronological. sync_tags(conn, &id, &input.body)?;
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES)) load_note(conn, &id)
.to_rfc3339_opts(SecondsFormat::Millis, true);
let recent: i64 = conn.query_row(
"SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2",
params![id, cutoff],
|r| r.get(0),
)?;
Ok(recent == 0)
} }
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> { fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
@@ -435,13 +391,10 @@ 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 body before changing it (version history) — but only // Snapshot the pre-edit body before changing it (version history).
// once per editing session, and only if it actually changed. See should_snapshot. if obj.contains_key("body") {
if let Some(body) = obj.get("body").and_then(|v| v.as_str()) {
if should_snapshot(conn, id, 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() {
@@ -555,32 +508,18 @@ pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite
load_note(conn, id) load_note(conn, id)
} }
// ---- checklist items: every one of these is a body edit ---------------------
//
// They keep their own names and signatures because the FFI, the Tauri commands and
// the REST shape all speak in items, and a checklist is still a thing a note HAS.
// What changed is where it is kept. Routing all three through `update_note` rather
// than writing the body directly is what gives them revision snapshotting, `#tag`
// re-derivation and the dirty/updated_at bookkeeping without any of it being
// written a second time here.
fn note_body(conn: &Connection, id: &str) -> rusqlite::Result<String> {
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
}
/// An item's id is its ordinal (see [items_of]). Anything else is a stale id from a
/// UI that has not reloaded, and the right answer to those is to do nothing.
fn item_index(item_id: &str) -> Option<usize> {
item_id.parse::<usize>().ok()
}
fn set_body(conn: &Connection, id: &str, body: String) -> rusqlite::Result<Note> {
update_note(conn, id, &json!({ "body": body }))
}
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> { pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
let body = note_body(conn, id)?; let pos: i64 = conn.query_row(
set_body(conn, id, derive::append_item(&body, text, false)) "SELECT COALESCE(MAX(position), -1) + 1 FROM checklist_items WHERE note_id = ?1",
[id],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, text, pos],
)?;
touch(conn, id)?;
load_note(conn, id)
} }
pub fn update_item( pub fn update_item(
@@ -589,27 +528,29 @@ pub fn update_item(
item_id: &str, item_id: &str,
changes: &Value, changes: &Value,
) -> rusqlite::Result<Note> { ) -> rusqlite::Result<Note> {
let index = match item_index(item_id) {
Some(i) => i,
None => return load_note(conn, id),
};
let mut body = note_body(conn, id)?;
if let Some(text) = changes.get("text").and_then(Value::as_str) { if let Some(text) = changes.get("text").and_then(Value::as_str) {
body = derive::set_item_text(&body, index, text); conn.execute(
"UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3",
params![text, item_id, id],
)?;
} }
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) { if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
body = derive::set_item_checked(&body, index, checked); conn.execute(
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
params![checked, item_id, id],
)?;
} }
set_body(conn, id, body) touch(conn, id)?;
load_note(conn, id)
} }
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> { pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
let index = match item_index(item_id) { conn.execute(
Some(i) => i, "DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2",
None => return load_note(conn, id), params![item_id, id],
}; )?;
let body = note_body(conn, id)?; touch(conn, id)?;
set_body(conn, id, derive::remove_item(&body, index)) load_note(conn, id)
} }
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> { pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
+2 -2
View File
@@ -19,11 +19,11 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The sync wire protocol this client speaks. /// The sync wire protocol this client speaks.
pub const CLIENT_PROTOCOL_VERSION: u32 = 3; pub const CLIENT_PROTOCOL_VERSION: u32 = 2;
/// The oldest server protocol this client can drive — the symmetric half of the /// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`. /// server's `min_client_protocol_version`.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3; pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the /// Capabilities without which syncing is meaningless, so their absence BLOCKS the
/// link rather than degrading it. /// link rather than degrading it.
+71 -21
View File
@@ -277,12 +277,34 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// Children are replaced wholesale: a delta carries the note's FULL current state, // Children are replaced wholesale: a delta carries the note's FULL current state,
// so "what the server sent" IS the complete set. Diffing would be more code and // so "what the server sent" IS the complete set. Diffing would be more code and
// could leave behind a row the server no longer has. // could leave behind a row the server no longer has.
replace_items(conn, note)?;
replace_attachments(conn, note)?; replace_attachments(conn, note)?;
replace_previews(conn, note)?; replace_previews(conn, note)?;
replace_labels(conn, note)?; replace_labels(conn, note)?;
Ok(()) Ok(())
} }
fn replace_items(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM checklist_items WHERE note_id = ?1",
params![note.id],
)?;
for (index, item) in note.items.iter().enumerate() {
conn.execute(
"INSERT INTO checklist_items (id, note_id, text, checked, position)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
item.id,
note.id,
item.text,
item.checked,
position_of(item.position, index)
],
)?;
}
Ok(())
}
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> { fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
conn.execute( conn.execute(
"DELETE FROM attachments WHERE note_id = ?1", "DELETE FROM attachments WHERE note_id = ?1",
@@ -371,6 +393,16 @@ fn ensure_label_stub(conn: &Connection, label: &wire::NoteLabel) -> rusqlite::Re
Ok(()) Ok(())
} }
/// Trust an explicit position; fall back to arrival order when the server sent 0 for
/// everything (which is what an unordered list looks like on the wire).
fn position_of(explicit: i64, index: usize) -> i64 {
if explicit > 0 {
explicit
} else {
index as i64
}
}
/// Loop the feed to exhaustion, starting from the persisted cursor. /// Loop the feed to exhaustion, starting from the persisted cursor.
/// ///
/// NOTE ON ORDERING: the full cycle is push-then-pull (docs/sync.md). Running this /// NOTE ON ORDERING: the full cycle is push-then-pull (docs/sync.md). Running this
@@ -476,22 +508,12 @@ mod tests {
sync_revision: revision, sync_revision: revision,
purged_at: None, purged_at: None,
labels: vec![], labels: vec![],
items: vec![],
attachments: vec![], attachments: vec![],
previews: vec![], previews: vec![],
} }
} }
fn attachment(id: &str) -> wire::Attachment {
wire::Attachment {
id: id.to_string(),
url: "/blob/x".into(),
filename: None,
mime: "image/png".into(),
size: None,
sha256: None,
}
}
fn page(notes: Vec<wire::Note>, labels: Vec<wire::Label>, cursor: i64) -> wire::ChangesPage { fn page(notes: Vec<wire::Note>, labels: Vec<wire::Label>, cursor: i64) -> wire::ChangesPage {
wire::ChangesPage { wire::ChangesPage {
notes, notes,
@@ -590,20 +612,35 @@ mod tests {
#[test] #[test]
fn children_are_replaced_not_merged() { fn children_are_replaced_not_merged() {
// Was written over checklist items; they are lines of the body now (M304), so
// attachments carry the point instead. It is the same property either way: a
// delta is the note's FULL current state, so a child the server dropped has to
// disappear locally rather than linger.
let conn = db(); let conn = db();
let mut first = note("n1", 1); let mut first = note("n1", 1);
first.attachments = vec![attachment("a1"), attachment("a2")]; first.items = vec![
wire::Item {
id: "i1".into(),
text: "one".into(),
checked: false,
position: 0,
},
wire::Item {
id: "i2".into(),
text: "two".into(),
checked: false,
position: 1,
},
];
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply"); apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 2); assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 2);
// The server dropped an item; the local copy must drop it too.
let mut second = note("n1", 2); let mut second = note("n1", 2);
second.attachments = vec![attachment("a1")]; second.items = vec![wire::Item {
id: "i1".into(),
text: "one".into(),
checked: true,
position: 0,
}];
apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply"); apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 1); assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 1);
} }
#[test] #[test]
@@ -773,10 +810,23 @@ mod tests {
fn a_page_that_fails_leaves_the_cursor_untouched() { fn a_page_that_fails_leaves_the_cursor_untouched() {
// Atomicity is the whole resumability story: a cursor committed ahead of its // Atomicity is the whole resumability story: a cursor committed ahead of its
// data would skip those rows forever. Force a failure with a duplicate // data would skip those rows forever. Force a failure with a duplicate
// attachment id inside one page. // checklist-item id inside one page.
let conn = db(); let conn = db();
let mut n = note("n1", 3); let mut n = note("n1", 3);
n.attachments = vec![attachment("dup"), attachment("dup")]; n.items = vec![
wire::Item {
id: "dup".into(),
text: "one".into(),
checked: false,
position: 0,
},
wire::Item {
id: "dup".into(),
text: "two".into(),
checked: false,
position: 1,
},
];
assert!(apply_page(&conn, &page(vec![n], vec![], 3)).is_err()); assert!(apply_page(&conn, &page(vec![n], vec![], 3)).is_err());
assert_eq!(state::read(&conn).expect("state").last_cursor, 0); assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0); assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
+24
View File
@@ -79,6 +79,8 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<i64>, pub position: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Vec<ItemOut>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label_ids: Option<Vec<String>>, pub label_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>, pub created_at: Option<String>,
@@ -101,6 +103,7 @@ impl Change {
remind_at: None, remind_at: None,
recurrence: None, recurrence: None,
position: None, position: None,
items: None,
label_ids: None, label_ids: None,
created_at: None, created_at: None,
name: None, name: None,
@@ -108,6 +111,12 @@ impl Change {
} }
} }
#[derive(Debug, Serialize)]
pub struct ItemOut {
pub text: String,
pub checked: bool,
}
// --- incoming results -------------------------------------------------------- // --- incoming results --------------------------------------------------------
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -192,6 +201,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
remind_at: None, remind_at: None,
recurrence: None, recurrence: None,
position: None, position: None,
items: None,
label_ids: None, label_ids: None,
created_at: None, created_at: None,
}) })
@@ -257,6 +267,19 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> { fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
let row = note_row(conn, id)?; let row = note_row(conn, id)?;
let items = {
let mut stmt = conn.prepare(
"SELECT text, checked FROM checklist_items WHERE note_id = ?1 ORDER BY position",
)?;
let rows = stmt.query_map(params![id], |r| {
Ok(ItemOut {
text: r.get(0)?,
checked: r.get::<_, i64>(1)? != 0,
})
})?;
rows.collect::<rusqlite::Result<Vec<ItemOut>>>()?
};
// MANUAL memberships only. Tag-sourced ones (`via_tag = 1`) are re-derived by the // MANUAL memberships only. Tag-sourced ones (`via_tag = 1`) are re-derived by the
// server from the body; sending them as label_ids would convert them into manual // server from the body; sending them as label_ids would convert them into manual
// assignments that no longer disappear when the #tag is removed from the text. // assignments that no longer disappear when the #tag is removed from the text.
@@ -282,6 +305,7 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
remind_at: row.remind_at, remind_at: row.remind_at,
recurrence: row.recurrence, recurrence: row.recurrence,
position: Some(row.position), position: Some(row.position),
items: Some(items),
label_ids: Some(label_ids), label_ids: Some(label_ids),
created_at: Some(row.created_at), created_at: Some(row.created_at),
name: None, name: None,
+13
View File
@@ -58,6 +58,8 @@ pub struct Note {
#[serde(default)] #[serde(default)]
pub labels: Vec<NoteLabel>, pub labels: Vec<NoteLabel>,
#[serde(default)] #[serde(default)]
pub items: Vec<Item>,
#[serde(default)]
pub attachments: Vec<Attachment>, pub attachments: Vec<Attachment>,
#[serde(default)] #[serde(default)]
pub previews: Vec<Preview>, pub previews: Vec<Preview>,
@@ -85,6 +87,17 @@ pub struct NoteLabel {
pub via_tag: bool, pub via_tag: bool,
} }
#[derive(Debug, Clone, Deserialize)]
pub struct Item {
pub id: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub checked: bool,
#[serde(default)]
pub position: i64,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct Attachment { pub struct Attachment {
pub id: String, pub id: String,
+1 -28
View File
@@ -3,12 +3,7 @@ import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown"; import { parseMarkdown } from "../notes/markdown";
import MarkdownInline from "./MarkdownInline.vue"; import MarkdownInline from "./MarkdownInline.vue";
const props = defineProps<{ text: string; toggleable?: boolean }>(); const props = defineProps<{ text: string }>();
// Ticking a box rewrites a line of the note's body, which is a thing only the owner
// of that note can do — so this renders the checkbox and hands the intent up rather
// than reaching for the store itself. The card wires it; a read-only render does not
// pass `toggleable` and the boxes are inert.
const emit = defineEmits<{ toggle: [index: number, checked: boolean] }>();
const blocks = computed(() => parseMarkdown(props.text)); const blocks = computed(() => parseMarkdown(props.text));
</script> </script>
@@ -24,28 +19,6 @@ const blocks = computed(() => parseMarkdown(props.text));
> >
<MarkdownInline :tokens="b.inline ?? []" /> <MarkdownInline :tokens="b.inline ?? []" />
</blockquote> </blockquote>
<div v-else-if="b.type === 'task'" class="flex flex-col gap-1">
<div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2">
<!-- Not wrapped in a <label>: on a card the text is the note's own words and
clicking it opens the note, so only the box itself toggles. `.stop` for
the same reason — the card is a click target underneath. -->
<input
type="checkbox"
class="mt-0.5 h-4 w-4 shrink-0 accent-brand"
:checked="b.tasks?.[j]?.checked ?? false"
:disabled="!toggleable"
:aria-label="toggleable ? 'Toggle item' : undefined"
@click.stop
@change="emit('toggle', b.tasks?.[j]?.index ?? 0, ($event.target as HTMLInputElement).checked)"
/>
<span
class="min-w-0 flex-1"
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
>
<MarkdownInline :tokens="it" />
</span>
</div>
</div>
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5"> <ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li> <li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
</ul> </ul>
+9 -14
View File
@@ -13,6 +13,7 @@ import type { Note } from "../stores/notes";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import MarkdownText from "./MarkdownText.vue"; import MarkdownText from "./MarkdownText.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { import {
cardIdAt, cardIdAt,
draggingId, draggingId,
@@ -93,15 +94,6 @@ const bodyPreview = computed(() => {
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…"; return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
}); });
/** Tick a box without opening the note — the common gesture on a board.
*
* The index is the item's ordinal in the WHOLE body, which survives the preview
* clamp above because that only ever drops lines from the end. `updateItem` takes
* it as the item id, which is exactly what an id is now (M304). */
function toggleTask(index: number, checked: boolean) {
void notes.updateItem(props.note.id, String(index), { checked });
}
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a // --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
@@ -271,7 +263,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
blank and the link is never unreachable. --> blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" /> <LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300"> <div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" toggleable @toggle="toggleTask" /> <MarkdownText :text="bodyPreview" />
</div> </div>
<p <p
v-if="!note.body && !note.items.length && !note.attachments.length" v-if="!note.body && !note.items.length && !note.attachments.length"
@@ -287,10 +279,13 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact /> <LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
</div> </div>
<!-- No separate checklist block any more. A checklist is lines of the body (M304), <NoteChecklist
so MarkdownText above draws it in place — which is what lets a list sit between v-if="note.items.length"
two paragraphs instead of always after them. Rendering both would have shown :class="note.body ? 'mt-2' : ''"
every list twice. --> :note-id="note.id"
:items="note.items"
@click="emit('open', note)"
/>
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1"> <div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span <span
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from "vue";
import { useNotesStore, type ChecklistItem } from "../stores/notes";
const props = defineProps<{ noteId: string; items: ChecklistItem[]; editable?: boolean }>();
const notes = useNotesStore();
const newItem = ref("");
async function addItem() {
const text = newItem.value.trim();
if (!text) return;
await notes.addItem(props.noteId, text);
newItem.value = "";
}
function toggle(item: ChecklistItem) {
void notes.updateItem(props.noteId, item.id, { checked: !item.checked });
}
function editText(item: ChecklistItem, value: string) {
if (value !== item.text) void notes.updateItem(props.noteId, item.id, { text: value });
}
function remove(item: ChecklistItem) {
void notes.deleteItem(props.noteId, item.id);
}
</script>
<template>
<div class="flex flex-col gap-1">
<div v-for="item in items" :key="item.id" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="item.checked"
@change="toggle(item)"
@click.stop
/>
<input
v-if="editable"
:value="item.text"
class="min-w-0 flex-1 bg-transparent text-sm outline-none"
:class="item.checked ? 'text-neutral-400 line-through' : ''"
@change="editText(item, ($event.target as HTMLInputElement).value)"
/>
<span
v-else
class="min-w-0 flex-1 truncate text-sm"
:class="item.checked ? 'text-neutral-400 line-through' : 'text-neutral-700 dark:text-neutral-300'"
>{{ item.text }}</span
>
<button
v-if="editable"
type="button"
class="hover-reveal text-neutral-300 opacity-0 hover:text-neutral-600 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="remove(item)"
>
×
</button>
</div>
<form v-if="editable" class="mt-1 flex items-center gap-2" @submit.prevent="addItem">
<span class="h-4 w-4 shrink-0" />
<input
v-model="newItem"
type="text"
placeholder="+ List item"
class="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-neutral-400"
/>
</form>
<p v-if="!editable && items.length === 0" class="text-sm italic text-neutral-400">Empty checklist</p>
</div>
</template>
+48 -160
View File
@@ -5,19 +5,12 @@ import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue"; import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { fromLocalInput, toLocalInput } from "../notes/datetime"; import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph"; import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion"; import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes"; import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors"; import { LABEL_CHIP_CLASSES, type NoteColor } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
joinBlocks,
plusTask,
splitBlocks,
withoutIndex,
} from "../notes/blocks";
// One modal editor for BOTH composing and editing — a single surface (the board's // One modal editor for BOTH composing and editing — a single surface (the board's
// "Take a note…" bar just opens this in compose mode). `note` = the note being edited, // "Take a note…" bar just opens this in compose mode). `note` = the note being edited,
@@ -32,49 +25,16 @@ const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void
const notes = useNotesStore(); const notes = useNotesStore();
const noteId = ref<string | null>(props.note?.id ?? null); const noteId = ref<string | null>(props.note?.id ?? null);
// BLOCKS rather than one string, because a checklist item is drawn as a real checkbox const body = ref(props.note?.body ?? props.initialBody);
// and a widget cannot live inside a <textarea>. The note is still one markdown body —
// see notes/blocks.ts — and `body` is what every save, baseline and draft still reads.
const blocks = ref<EditorBlock[]>(splitBlocks(props.note?.body ?? props.initialBody));
const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
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] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2) // Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns // rather than BEING one, so this is a view flag, not a property of the note: it turns
// on when the note already carries items, and when someone asks for one. // on when the note already carries items, and when someone asks for one.
const checklistOpen = ref(false);
const saving = ref(false); const saving = ref(false);
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
// One element per block, keyed by the block's id — the only thing about a block that const bodyInput = ref<HTMLTextAreaElement | null>(null);
// survives one being inserted above it. Focus is asked for by id and honoured after
// the render that created the field, since an element that does not exist yet cannot
// take it.
const blockEls = new Map<number, HTMLTextAreaElement | HTMLInputElement>();
function setBlockEl(id: number, el: unknown): void {
if (el) blockEls.set(id, el as HTMLTextAreaElement);
else blockEls.delete(id);
}
async function focusBlock(id: number | null): Promise<void> {
if (id === null) return;
await nextTick();
const el = blockEls.get(id);
el?.focus();
if (el) el.selectionStart = el.selectionEnd = el.value.length;
}
/** A prose field sized to its text. `rows="1"` plus this beats guessing a row count,
* which is wrong the moment a line wraps. */
function grow(el: HTMLTextAreaElement): void {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}
function growAll(): void {
for (const el of blockEls.values()) {
if (el instanceof HTMLTextAreaElement) grow(el);
}
}
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref(""); const uploadError = ref("");
@@ -115,6 +75,15 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value) ? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value, : draftNote.value,
); );
// The checklist renders once the note has items, or once someone has asked for one.
// It sits BELOW the body rather than instead of it — a note can carry both, which is
// the whole point of the merge.
//
// Items need a persisted note to hang off, so this is a rich action like attaching a
// file: in compose it waits for the draft to be saved.
const showChecklist = computed(
() => !isCreate.value && (liveNote.value.items.length > 0 || checklistOpen.value),
);
const bodyPlaceholder = "Take a note…"; const bodyPlaceholder = "Take a note…";
// Keep local state in sync when the edited note changes (modal reused for another note). // Keep local state in sync when the edited note changes (modal reused for another note).
@@ -122,7 +91,7 @@ watch(
() => props.note, () => props.note,
(n) => { (n) => {
noteId.value = n?.id ?? null; noteId.value = n?.id ?? null;
setBody(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 = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor }; baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
@@ -173,9 +142,10 @@ async function flush(): Promise<void> {
function resetCompose(): void { function resetCompose(): void {
noteId.value = null; noteId.value = null;
setBody(""); body.value = "";
color.value = "default"; color.value = "default";
labelList.value = []; labelList.value = [];
checklistOpen.value = false;
baseline.value = { body: "", color: "default" }; baseline.value = { body: "", color: "default" };
uploadError.value = ""; uploadError.value = "";
} }
@@ -187,9 +157,7 @@ async function commitAndContinue(): Promise<void> {
await flush(); await flush();
resetCompose(); resetCompose();
await nextTick(); await nextTick();
growAll(); bodyInput.value?.focus();
const first = blocks.value[0];
await focusBlock(first ? first.id : null);
} }
// ---- open/close animation (M7) ---- // ---- open/close animation (M7) ----
// //
@@ -259,68 +227,21 @@ function onBackdropMousedown(): void {
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
growAll(); const el = bodyInput.value;
// The LAST block, with the caret after its text: opening a note means continuing it, el?.focus();
// and type-to-compose seeds text that should be typed straight on from. // Put the caret after any seeded text (type-to-compose) so typing continues cleanly.
const last = blocks.value[blocks.value.length - 1]; if (el) el.selectionStart = el.selectionEnd = el.value.length;
await focusBlock(last ? last.id : null);
}); });
/** Editing one block: replace its text, leave every other block alone. */ function onBodyKeydown(e: KeyboardEvent) {
function setText(index: number, text: string): void { // Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
const out = [...blocks.value];
out[index] = { ...out[index], text };
blocks.value = out;
}
function setChecked(index: number, checked: boolean): void {
const out = [...blocks.value];
out[index] = { ...out[index], checked };
blocks.value = out;
}
function onProseInput(index: number, e: Event): void {
const el = e.target as HTMLTextAreaElement;
setText(index, el.value);
grow(el);
}
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
function onProseKeydown(e: KeyboardEvent): void {
if (isCreate.value && e.key === "Enter" && e.shiftKey) { if (isCreate.value && e.key === "Enter" && e.shiftKey) {
e.preventDefault(); e.preventDefault();
void commitAndContinue(); void commitAndContinue();
return;
} }
} }
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
function onTaskEnter(index: number): void {
const next = afterEnter(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
}
/**
* Backspace at the very start of an EMPTY item removes it.
*
* Worth having on the web where Android's is not: a browser sends a real keydown for
* Backspace, while an Android soft keyboard sends an IME delete that never surfaces as
* one. Enter-on-empty ends a list on both surfaces; this is the extra way out that
* only one of them can offer.
*/
function onTaskBackspace(index: number, e: KeyboardEvent): void {
const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault();
removeBlock(index);
}
function removeBlock(index: number): void {
const next = withoutIndex(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.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) {
@@ -364,17 +285,14 @@ function labelChip(c: string): string {
// ---- add a checklist ---- // ---- add a checklist ----
// //
// Appends an empty item and puts the caret in it. Unlike every other toolbar button // Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
// this one needs NO persisted note to hang anything off — a checklist is part of the // body and gains a place to put items. Persists the draft first for the same reason
// body (M304), so it works on an empty compose box the moment it opens. // attaching a file does — an item needs a note to belong to.
// async function addChecklist() {
// Appends rather than inserting at the caret because a block editor has no single if (checklistOpen.value) return;
// caret to insert at: the field that had focus may not be the one being looked at by const id = await ensureDraft();
// the time this runs. if (!id) return;
function addChecklist(): void { checklistOpen.value = true;
const next = plusTask(blocks.value);
blocks.value = next.blocks;
void focusBlock(next.focus);
} }
// ---- attachments ---- // ---- attachments ----
@@ -453,7 +371,7 @@ 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);
setBody(updated.body); body.value = updated.body;
color.value = updated.color; color.value = updated.color;
baseline.value = { 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
@@ -556,52 +474,22 @@ function revPreview(rev: NoteRevision): string {
/> />
</div> </div>
<!-- The body, as fields and checkboxes rather than as markup. A checklist
item is a real input; a run of prose is one textarea, so typing a
paragraph still feels like typing a paragraph. -->
<div class="flex flex-col gap-1">
<template v-for="(block, i) in blocks" :key="block.id">
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
<input
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="block.checked"
:aria-label="block.text || 'Checklist item'"
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
/>
<input
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
type="text"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="onTaskEnter(i)"
@keydown.backspace="onTaskBackspace(i, $event)"
/>
<button
type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@click="removeBlock(i)"
>
×
</button>
</div>
<textarea <textarea
v-else ref="bodyInput"
:ref="(el) => setBlockEl(block.id, el)" v-model="body"
:value="block.text" rows="8"
rows="1" :placeholder="bodyPlaceholder"
:placeholder="i === 0 ? bodyPlaceholder : ''" class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400" @keydown="onBodyKeydown"
@input="onProseInput(i, $event)" />
@keydown="onProseKeydown" <!-- Below the body, not instead of it. -->
<NoteChecklist
v-if="showChecklist"
class="py-1"
:note-id="liveNote.id"
:items="liveNote.items"
editable
/> />
</template>
</div>
<!-- No checklist component. The items are lines of the textarea above, which
is what lets a list sit between two paragraphs (M304). -->
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1"> <div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span <span
@@ -710,7 +598,7 @@ function revPreview(rev: NoteRevision): string {
</button> </button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" /> <input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button <button
v-if="!liveNote.trashed" v-if="richEnabled && !liveNote.trashed && !showChecklist"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Add a checklist" title="Add a checklist"
-110
View File
@@ -1,110 +0,0 @@
// The editor's shape for a note body: a list of blocks rather than one string.
//
// The note is still one markdown body underneath (M304) — this is a rendering and
// input shape, and nothing below the editor can tell it exists. `joinBlocks` puts the
// string back together on every edit, and for a body already in canonical form it
// returns exactly what `splitBlocks` was handed.
//
// Why blocks at all: a checklist item has to be a real `<input type="checkbox">`, and
// a widget cannot live inside a `<textarea>`. Only a `contenteditable` could hold one,
// and that is a different editor with a different set of problems.
//
// A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
// like typing a paragraph, and a separate field under every sentence would break the
// caret mid-sentence. Only a checklist item earns a block, because only a checklist
// item needs a widget.
//
// The mirror of android/.../ui/EditorBlock.kt, deliberately: the two editors should
// behave the same, and the cheapest way to keep them that way is for the shapes to
// read alike.
import { parseTaskLine, renderTaskLine } from "./markdown";
export interface EditorBlock {
/** Stable across edits, so Vue keeps a field's caret when a block is inserted above
* it. Content cannot serve as the key — two empty items are identical and neither
* is the other. */
id: number;
text: string;
/** null for prose; ticked-or-not for a checklist item. */
checked: boolean | null;
}
/** Split a body into blocks, numbering them from `firstId`. */
export function splitBlocks(body: string, firstId = 0): EditorBlock[] {
const out: EditorBlock[] = [];
const prose: string[] = [];
let id = firstId;
const flushProse = () => {
if (prose.length) {
out.push({ id: id++, text: prose.join("\n"), checked: null });
prose.length = 0;
}
};
for (const line of (body ?? "").split("\n")) {
const task = parseTaskLine(line);
if (task) {
flushProse();
out.push({ id: id++, text: task.text, checked: task.checked });
} else {
prose.push(line);
}
}
flushProse();
// Never empty: an empty note still needs one field to type into.
return out.length ? out : [{ id, text: "", checked: null }];
}
/** The body those blocks stand for. */
export function joinBlocks(blocks: EditorBlock[]): string {
return blocks.map((b) => (b.checked === null ? b.text : renderTaskLine(b.text, b.checked))).join("\n");
}
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
export function nextId(blocks: EditorBlock[]): number {
return blocks.reduce((max, b) => Math.max(max, b.id), -1) + 1;
}
/**
* What Enter does on a checklist item, and which block should hold the caret after.
*
* On an item with words in it, a new empty item below. On an EMPTY one, the item
* becomes prose — which is how a list ENDS, and the only way to get a paragraph after
* one. Without that half a list is impossible to get out of.
*
* Appends rather than splitting at the caret: splitting an item in two is a rarity,
* and the caret is at the end for every ordinary use of that key.
*/
export function afterEnter(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number } {
const block = blocks[index];
const out = [...blocks];
if (!block.text.trim()) {
out[index] = { ...block, text: "", checked: null };
return { blocks: out, focus: block.id };
}
const id = nextId(blocks);
out.splice(index + 1, 0, { id, text: "", checked: false });
return { blocks: out, focus: id };
}
/**
* Drop a block, leaving at least one field to type into.
*
* Focus goes to the block above — or, when the first one was removed, to whichever
* takes its place. `index - 1` alone is -1 there, which would leave nothing focused.
*/
export function withoutIndex(blocks: EditorBlock[], index: number): { blocks: EditorBlock[]; focus: number | null } {
const kept = blocks.filter((_, i) => i !== index);
const fallback: EditorBlock[] = [{ id: nextId(blocks), text: "", checked: null }];
const remaining = kept.length ? kept : fallback;
return { blocks: remaining, focus: remaining[Math.max(0, index - 1)]?.id ?? null };
}
/** One more empty checklist item at the end, and the id to put the caret in. */
export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus: number } {
const id = nextId(blocks);
return { blocks: [...blocks, { id, text: "", checked: false }], focus: id };
}
+1 -66
View File
@@ -13,22 +13,10 @@ export interface InlineToken {
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple. // Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
export interface Block { export interface Block {
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre" | "task"; type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre";
inline?: InlineToken[]; inline?: InlineToken[];
items?: InlineToken[][]; items?: InlineToken[][];
value?: string; value?: string;
/** `task` only: one entry per `items` entry, parallel by position. */
tasks?: TaskMeta[];
}
export interface TaskMeta {
/** This item's ordinal among ALL task lines in the body, in document order.
* That is the id the server and the native clients address an item by, so a
* checkbox can be toggled straight from it. Counted across blocks, not within
* one, and unaffected by the card truncating the body — the card only ever
* drops lines from the END. */
index: number;
checked: boolean;
} }
// Order matters: code is matched before emphasis so its contents aren't re-parsed; // Order matters: code is matched before emphasis so its contents aren't re-parsed;
@@ -56,45 +44,11 @@ export function parseInline(text: string): InlineToken[] {
return tokens; return tokens;
} }
// A checklist item: the third implementation of one grammar, alongside
// core/src/local/derive.rs and src/thoughtsync/notes/checklist.py. A difference
// between any two of them is a checklist that changes shape when it syncs (M304).
//
// `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`.
// The other two implementations do not take `+`, and one grammar in three places has
// to be one grammar; a `+ [ ] x` line renders as an ordinary bullet everywhere,
// which is at least consistent.
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
export interface TaskLine {
checked: boolean;
text: string;
}
/** One task line's parts, or null when the line is prose.
*
* Exported so the EDITOR's block split (notes/blocks.ts) and this read-view parser
* agree by construction rather than by comment. One grammar, one matcher. */
export function parseTaskLine(line: string): TaskLine | null {
const m = TASK_RE.exec(line);
return m ? { checked: m[1] !== " ", text: m[2] ?? "" } : null;
}
/** One item as the body line that stores it, in canonical form — `- [x] `, lowercase,
* and no trailing space when the item is empty so a round trip does not grow it. */
export function renderTaskLine(text: string, checked: boolean): string {
const mark = checked ? "x" : " ";
return text ? `- [${mark}] ${text}` : `- [${mark}]`;
}
export function parseMarkdown(text: string): Block[] { export function parseMarkdown(text: string): Block[] {
const lines = (text ?? "").split("\n"); const lines = (text ?? "").split("\n");
const blocks: Block[] = []; const blocks: Block[] = [];
let paragraph: string[] = []; let paragraph: string[] = [];
let i = 0; let i = 0;
// Runs across the whole document, not per block, because that is what the item's
// id means everywhere else.
let taskIndex = 0;
const flushPara = () => { const flushPara = () => {
if (paragraph.length) { if (paragraph.length) {
@@ -142,25 +96,6 @@ export function parseMarkdown(text: string): Block[] {
continue; continue;
} }
// Task list, BEFORE the plain bullet below — which would otherwise swallow
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same
// ordering reason as `code` being matched before emphasis in INLINE_RE.
if (parseTaskLine(line)) {
flushPara();
const items: InlineToken[][] = [];
const tasks: TaskMeta[] = [];
while (i < lines.length) {
const task = parseTaskLine(lines[i]);
if (!task) break;
items.push(parseInline(task.text));
tasks.push({ index: taskIndex, checked: task.checked });
taskIndex++;
i++;
}
blocks.push({ type: "task", items, tasks });
continue;
}
// Unordered list: -, *, or + then a space. // Unordered list: -, *, or + then a space.
if (/^\s*[-*+]\s+/.test(line)) { if (/^\s*[-*+]\s+/.test(line)) {
flushPara(); flushPara();
+1
View File
@@ -9,6 +9,7 @@ from . import ( # noqa: F401
label, label,
note, note,
note_attachment, note_attachment,
note_item,
note_link_preview, note_link_preview,
note_revision, note_revision,
saved_filter, saved_filter,
+1
View File
@@ -46,6 +46,7 @@ class Note(Base):
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")
# 'text' (freeform body) or 'list' (a checklist of note_items).
# Manual drag order (higher = earlier); 0 until the user reorders. # Manual drag order (higher = earlier); 0 until the user reorders.
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0") position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class NoteItem(Base):
"""A single checklist item on a note.
Any note can have them. There is no note "kind" gating this — a checklist is
something a note HAS, not something a note IS (M13 step 2).
"""
__tablename__ = "note_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
note_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
text: Mapped[str] = mapped_column(Text(), nullable=False)
checked: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+75 -69
View File
@@ -31,16 +31,9 @@ from ..labeling import reconcile_manual_labels, resolve_owned_label_ids
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from ..models.note_link_preview import NoteLinkPreview from ..models.note_link_preview import NoteLinkPreview
from ..models.note_revision import NoteRevision from ..models.note_revision import NoteRevision
from ..revisions import should_snapshot
from .checklist import (
append_item,
parse_items,
remove_item,
set_item_checked,
set_item_text,
)
from ..responses import json_error, not_found, parse_uuid from ..responses import json_error, not_found, parse_uuid
from ..retention import purge_note from ..retention import purge_note
from ..settings import get_setting from ..settings import get_setting
@@ -76,7 +69,7 @@ from .tags import (
parse_tags, parse_tags,
) )
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
from .serialize import _labels_for_notes, _serialize_note, _serialize_notes from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes
__all__ = [ __all__ = [
"bp", "bp",
@@ -231,6 +224,7 @@ async def export_notes():
).all() ).all()
ids = [n.id for n in notes_list] ids = [n.id for n in notes_list]
labels_map = await _labels_for_notes(db, ids) labels_map = await _labels_for_notes(db, ids)
items_map = await _items_for_notes(db, ids)
att_rows = ( att_rows = (
(await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id.in_(ids)))).all() if ids else [] (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id.in_(ids)))).all() if ids else []
) )
@@ -252,6 +246,7 @@ async def export_notes():
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for n in notes_list: for n in notes_list:
labels = labels_map.get(n.id, []) labels = labels_map.get(n.id, [])
items = items_map.get(n.id, [])
atts = att_by_note.get(n.id, []) atts = att_by_note.get(n.id, [])
short = str(n.id)[:8] short = str(n.id)[:8]
payload["notes"].append( payload["notes"].append(
@@ -267,12 +262,13 @@ async def export_notes():
"created_at": n.created_at.isoformat() if n.created_at else None, "created_at": n.created_at.isoformat() if n.created_at else None,
"updated_at": n.updated_at.isoformat() if n.updated_at else None, "updated_at": n.updated_at.isoformat() if n.updated_at else None,
"labels": [lb["name"] for lb in labels], "labels": [lb["name"] for lb in labels],
"items": [{"text": it["text"], "checked": it["checked"]} for it in items],
"attachments": [ "attachments": [
{"file": f"attachments/{short}/{os.path.basename(a.path)}", "mime": a.mime} for a in atts {"file": f"attachments/{short}/{os.path.basename(a.path)}", "mime": a.mime} for a in atts
], ],
} }
) )
zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels)) zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels, items))
for a in atts: for a in atts:
src = Config.media_root() / a.path src = Config.media_root() / a.path
if src.is_file(): if src.is_file():
@@ -388,6 +384,24 @@ 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():
@@ -404,20 +418,17 @@ 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)
) )
) )
# Items still arrive separately — a client holds a list, not a blob — but they
# are folded into the body, which is where a checklist lives now (M304).
for text in item_texts:
body = append_item(body, text)
note = Note( note = Note(
owner_id=g.user_id, owner_id=g.user_id,
display_title=derive_display_title(body), display_title=derive_display_title(body, item_texts[0] if item_texts else None),
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,
) )
db.add(note) db.add(note)
await db.flush() # assign note.id before writing links await db.flush() # assign note.id before writing items/links
# The FOLDED body: an item can carry a #tag too. for pos, text in enumerate(item_texts):
db.add(NoteItem(note_id=note.id, text=text, position=pos))
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
@@ -472,12 +483,10 @@ async def update_note(note_id: str):
if "recurrence" in data: if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"]) note.recurrence = normalize_recurrence(data["recurrence"])
if "body" in data: if "body" in data:
note.display_title = derive_display_title(note.body) note.display_title = await _name_for(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
# Version history: snapshot the PRE-edit body, once per editing session # Version history: snapshot the PRE-edit body whenever it changed.
# rather than once per write — see revisions.should_snapshot. Writing often if note.body != old_body:
# is what lets a client autosave instead of hoarding text until it closes.
if await should_snapshot(db, note.id, old_body, note.body):
db.add(NoteRevision(note_id=note.id, 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)
@@ -531,7 +540,7 @@ async def restore_revision(note_id: str, rev_id: str):
# the revision — with the same body ripple as a normal edit. # the revision — with the same body ripple as a normal edit.
db.add(NoteRevision(note_id=note.id, body=note.body)) db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = rev.body note.body = rev.body
note.display_title = derive_display_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)
@@ -563,35 +572,11 @@ async def set_note_labels(note_id: str):
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note))
def _item_index(item_id: str) -> int | None: async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
"""An item's id is its ordinal (see serialize.items_of). Anything else is a stale iid = parse_uuid(item_id)
id from a client that has not reloaded, and the answer to those is 404.""" if iid is None:
try:
index = int(item_id)
except (TypeError, ValueError):
return None return None
return index if index >= 0 else None return await db.scalar(select(NoteItem).where(NoteItem.id == iid, NoteItem.note_id == note.id))
async def _rewrite_body(db, note: Note, body: str):
"""Every item mutation is a body edit, so all of them land here.
One place means one place that snapshots a revision, re-derives `#tags`, recomputes
the name and queues link unfurls — rather than three routes each remembering to.
Deliberately the same sequence the PATCH route runs for a body change, because it
IS a body change.
"""
old_body = note.body
if await should_snapshot(db, note.id, old_body, body):
db.add(NoteRevision(note_id=note.id, body=old_body))
note.body = body
note.display_title = derive_display_title(body)
await _reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
if note.body != old_body:
schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/items") @bp.post("/<note_id>/items")
@@ -603,49 +588,70 @@ async def add_item(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()
response = await _rewrite_body(db, note, append_item(note.body, text)) max_pos = await db.scalar(
return response, 201 select(func.coalesce(func.max(NoteItem.position), -1)).where(NoteItem.note_id == note.id)
)
db.add(NoteItem(note_id=note.id, text=text, position=int(max_pos) + 1))
await db.commit()
return jsonify(await _serialize_note(db, note)), 201
@bp.patch("/<note_id>/items/<item_id>") @bp.patch("/<note_id>/items/<item_id>")
@login_required @login_required
async def update_item(note_id: str, item_id: str): async def update_item(note_id: str, item_id: str):
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
index = _item_index(item_id)
if index is None:
return not_found()
async with session_scope() as db: async with session_scope() as db:
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()
if index >= len(parse_items(note.body)): item = await _get_item(db, note, item_id)
if item is None:
return not_found() return not_found()
body = note.body
if "text" in data and isinstance(data["text"], str): if "text" in data and isinstance(data["text"], str):
body = set_item_text(body, index, data["text"]) item.text = data["text"]
if "checked" in data: if "checked" in data:
body = set_item_checked(body, index, bool(data["checked"])) item.checked = bool(data["checked"])
return await _rewrite_body(db, note, body) await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.delete("/<note_id>/items/<item_id>") @bp.delete("/<note_id>/items/<item_id>")
@login_required @login_required
async def delete_item(note_id: str, item_id: str): async def delete_item(note_id: str, item_id: str):
index = _item_index(item_id)
if index is None:
return not_found()
async with session_scope() as db: async with session_scope() as db:
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()
if index >= len(parse_items(note.body)): item = await _get_item(db, note, item_id)
if item is None:
return not_found() return not_found()
return await _rewrite_body(db, note, remove_item(note.body, index)) await db.delete(item)
await db.commit()
return jsonify(await _serialize_note(db, note))
# The reorder route is gone with M304. Reordering a checklist is moving a line, which @bp.post("/<note_id>/items/reorder")
# is something a text editor already does and no client ever called this for — the @login_required
# only reference to it in the tree was a test asserting the route existed. async def reorder_items(note_id: str):
data = await request.get_json(silent=True) or {}
order = data.get("item_ids")
if not isinstance(order, list):
return json_error("item_ids must be a list", 400)
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
existing = {
str(i.id): i for i in (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
}
pos = 0
for iid in order:
item = existing.get(str(iid))
if item is not None:
item.position = pos
pos += 1
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/attachments") @bp.post("/<note_id>/attachments")
-150
View File
@@ -1,150 +0,0 @@
"""Checklist items — a note's body IS its checklist (M304).
A `- [ ] milk` line is the item. There is no `note_items` table beside the body any
more, which is what lets a list sit BETWEEN two paragraphs: rows had a position in a
table and no position in the text, so a separate list could only ever render after
the prose no matter how it was styled.
The same shape as `tags.py`, one strength further along. Tags are derived from the
body too, but they MATERIALISE into `note_labels` rows because the board queries by
label. Items materialise into nothing, because nothing queries them — their only
readers are the card, the editor and `display_title`. So `parse_items` is the whole
storage layer for a checklist, and the rewriters below are how one is edited.
THE GRAMMAR IS SHARED. Three implementations exist and they have to agree, because a
difference between any two of them is a checklist that changes shape when it syncs:
core/src/local/derive.rs the native clients (desktop + Android)
src/thoughtsync/notes/checklist.py this file, the server
frontend/src/notes/markdown.ts the browser
optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`,
then either end-of-line or one-or-more spaces and the text.
`*` is accepted because markdown.ts already takes it for a plain bullet, and a rule
that allowed `* item` but not `* [ ] item` would be one nobody could guess. `- [ ]`
with nothing after it IS an item with empty text — that is what pressing Enter on a
list leaves behind, and refusing to parse it would make a half-typed list stop being
a list. `- [X]` parses as checked and renders back lowercase, so one canonical form
survives a round trip.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# Anchored at both ends: a `[ ]` mid-sentence is prose, and `- [ ]x` (no space after
# the brackets) is a sentence that happens to start with brackets, not a marker.
_TASK_RE = re.compile(r"^(?P<indent>\s*)(?P<bullet>[-*]) +\[(?P<mark>[ xX])\](?: +(?P<text>.*))?$")
@dataclass(frozen=True)
class Item:
"""One checklist item. Its position in the parsed list is its identity — the same
thing `position` meant when these were rows, and all the wire ever carried."""
text: str
checked: bool
def parse_items(body: str | None) -> list[Item]:
"""Every checklist item in `body`, in the order they appear."""
out: list[Item] = []
for line in (body or "").split("\n"):
match = _TASK_RE.match(line)
if match:
out.append(Item(text=match.group("text") or "", checked=match.group("mark") in "xX"))
return out
def render_item(text: str, checked: bool, indent: str = "", bullet: str = "-") -> str:
"""One item as the line that stores it.
Always lowercase `x`, whatever was parsed: one canonical output is what makes a
round trip stable, so `- [X]` normalises the first time it is touched and never
again.
"""
mark = "x" if checked else " "
if not text:
return f"{indent}{bullet} [{mark}]"
return f"{indent}{bullet} [{mark}] {text}"
def strip_marker(line: str) -> str:
"""The text of a line with its task marker removed, or the line as it was.
For naming a note: a list-only note is named by its first item, and calling one
"- [ ] milk" would be showing someone the storage instead of the note.
"""
match = _TASK_RE.match(line)
return (match.group("text") or "") if match else line
def _rewrite(body: str, index: int, replace) -> str:
"""Rewrite the `index`-th task line with `replace`, or drop it when `replace`
returns None.
A body with fewer task lines than that is returned UNCHANGED rather than raising:
the index comes from a client that may be a moment behind the server, and a stale
request should do nothing rather than 500.
"""
lines = body.split("\n")
target = None
seen = 0
for n, line in enumerate(lines):
if _TASK_RE.match(line):
if seen == index:
target = n
break
seen += 1
if target is None:
return body
match = _TASK_RE.match(lines[target])
replacement = replace(match)
if replacement is None:
del lines[target]
else:
lines[target] = replacement
return "\n".join(lines)
def set_item_checked(body: str, index: int, checked: bool) -> str:
"""Tick or untick the `index`-th item, keeping its text, indent and bullet."""
return _rewrite(
body,
index,
lambda m: render_item(m.group("text") or "", checked, m.group("indent"), m.group("bullet")),
)
def set_item_text(body: str, index: int, text: str) -> str:
"""Replace the text of the `index`-th item, keeping its state and its bullet."""
return _rewrite(
body,
index,
lambda m: render_item(text.strip(), m.group("mark") in "xX", m.group("indent"), m.group("bullet")),
)
def remove_item(body: str, index: int) -> str:
"""Delete the `index`-th item, line and all."""
return _rewrite(body, index, lambda _m: None)
def append_item(body: str, text: str, checked: bool = False) -> str:
"""Add an item at the end of the body.
A blank line between prose and the list, nothing between consecutive items —
the layout `import_export._note_markdown` has always used when writing a checklist
out. That is not cosmetic: it is what the Alembic migration folds existing
`note_items` rows into AND what `derive::append_item` produces on every client, so
all three land on identical bodies. An export taken before the migration and one
taken after therefore differ in nothing.
"""
line = render_item(text.strip(), checked)
trimmed = body.rstrip("\n")
if not trimmed.strip():
return line
follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1]))
return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}"
+13 -22
View File
@@ -3,8 +3,6 @@ board-filter narrowing, the owner-scoped fetch, and filename/slug sanitizers use
both the attachment routes and the importer.""" both the attachment routes and the importer."""
from __future__ import annotations from __future__ import annotations
from .checklist import strip_marker
import os import os
import re import re
@@ -21,36 +19,29 @@ VALID_FILTERS = {"active", "archived", "trash"}
DISPLAY_TITLE_CAP = 200 DISPLAY_TITLE_CAP = 200
def derive_display_title(body: str | None) -> str: def derive_display_title(body: str | None, first_item: str | None = None) -> str:
"""The note's display NAME: the first line of the body that says anything. """The note's display NAME: the first non-empty line of the body, else the first
checklist item's text (both trimmed and length-capped).
There is no explicit title to prefer any more (M13 step 3) — a note is a body, and There is no explicit title to prefer any more (M13 step 3) — a note is a body plus
its name is simply the first thing written in it. Persisted as notes.display_title optional items, and its name is simply the first thing written in it. Persisted as
so search results and export filenames have something to say. notes.display_title so search results and export filenames have something to say.
The old `first_item` fallback is gone with M304: items ARE body lines now, so a The item fallback is what step 2 bought: a note that is only a checklist would
list-only note is named by its first item without anyone having to arrange it. What otherwise have no name at all, which is exactly the hole that made removing the
replaced the fallback is stripping the task marker — calling that note "- [ ] milk" title unsafe before checklists stopped being their own kind of thing.
would show someone the storage instead of the note — and skipping an EMPTY item, so
a half-typed list does not leave a note with no name.
Mirrors `display_title` in core/src/local/store.rs. Deterministic — a literal first Deterministic — a literal first line, never generated.
line, never generated.
""" """
for line in (body or "").splitlines(): for line in (body or "").splitlines():
stripped = strip_marker(line.strip()).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(body: str | None, items: list | None = None) -> bool: def is_empty_note(body: str | None, items: list | None = None) -> bool:
"""Nothing worth keeping: no body text and no checklist items. """Nothing worth keeping: no body text and no checklist items."""
`items` is still a separate argument because create still accepts them separately —
the importer holds a list, not a blob — and they are folded into the body only
after this check has decided the note is worth making at all.
"""
return not (body or "").strip() and not items return not (body or "").strip() and not items
+14 -15
View File
@@ -20,6 +20,7 @@ from ..config import Config
from ..models.label import NoteLabel from ..models.label import NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from .helpers import ( from .helpers import (
ALLOWED_IMAGE_MIMES, ALLOWED_IMAGE_MIMES,
_attachment_ext, _attachment_ext,
@@ -27,12 +28,11 @@ from .helpers import (
derive_display_title, derive_display_title,
is_empty_note, is_empty_note,
) )
from .checklist import append_item
from .tags import _find_or_create_label, _reconcile_tags from .tags import _find_or_create_label, _reconcile_tags
from .recurrence import normalize_recurrence from .recurrence import normalize_recurrence
def _note_markdown(note: Note, labels: list) -> str: 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 = ["---"]
@@ -54,9 +54,11 @@ def _note_markdown(note: Note, labels: list) -> str:
# are written, body first, with a blank line between them when there is. # are written, body first, with a blank line between them when there is.
if note.body: if note.body:
fm.append(note.body) fm.append(note.body)
# No separate items block any more. The body already ends with those exact lines if items:
# (M304) — this function is where their layout was decided, and appending them a if note.body:
# second time would double every checklist in an export. fm.append("")
for it in items:
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
return "\n".join(fm) + "\n" return "\n".join(fm) + "\n"
@@ -292,17 +294,9 @@ async def _create_imported_note(
if is_empty_note(body, item_texts): if is_empty_note(body, item_texts):
return False return False
# Items fold into the body, which is where a checklist lives now (M304). Done
# before the Note is built so display_title and _reconcile_tags both see the
# finished text — an imported item can carry a #tag like any other line.
for it in items:
text = (it.get("text") or "").strip()
if text:
body = append_item(body, text, bool(it.get("checked")))
note = Note( note = Note(
owner_id=owner_id, owner_id=owner_id,
display_title=derive_display_title(body), display_title=derive_display_title(body, item_texts[0] if item_texts else None),
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")),
@@ -322,7 +316,12 @@ async def _create_imported_note(
if spec.get("updated_at"): if spec.get("updated_at"):
note.updated_at = spec["updated_at"] note.updated_at = spec["updated_at"]
db.add(note) db.add(note)
await db.flush() # assign note.id before labels/attachments/links await db.flush() # assign note.id before items/labels/attachments/links
for pos, it in enumerate(items):
text = (it.get("text") or "").strip()
if text:
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
# Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the # Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the
# body are handled by _reconcile_tags below, same as a normal create. # body are handled by _reconcile_tags below, same as a normal create.
+20 -19
View File
@@ -8,8 +8,8 @@ from sqlalchemy import select
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from ..models.note_link_preview import NoteLinkPreview from ..models.note_link_preview import NoteLinkPreview
from .checklist import parse_items
async def _labels_for_notes(db, note_ids: list) -> dict: async def _labels_for_notes(db, note_ids: list) -> dict:
@@ -30,23 +30,23 @@ async def _labels_for_notes(db, note_ids: list) -> dict:
return result return result
def items_of(body: str | None) -> list[dict]: def _serialize_item(item: NoteItem) -> dict:
"""The note's checklist, read out of its body. No query, because there is no table. return {"id": str(item.id), "text": item.text, "checked": item.checked, "position": item.position}
Still emitted in the payload after M304, and that is not a second source of truth:
it is DERIVED on the way out, so it cannot disagree with the body it came from. It
saves every consumer that only wants to draw checkboxes from carrying a parser, and
the ones that do carry one (the native clients, the browser) are free to ignore it
and read the body.
The id is the item's ORDINAL, which is what the rewriters in `checklist.py` take, async def _items_for_notes(db, note_ids: list) -> dict:
so a client holding one can act on it directly. It also shifts when an item is """Map note_id -> [checklist items] in one query, ordered by position."""
removed — every mutation returns the reloaded note for exactly that reason. result: dict = {}
""" if not note_ids:
return [ return result
{"id": str(i), "text": item.text, "checked": item.checked, "position": i} items = (
for i, item in enumerate(parse_items(body)) await db.scalars(
] select(NoteItem).where(NoteItem.note_id.in_(note_ids)).order_by(NoteItem.position, NoteItem.created_at)
)
).all()
for item in items:
result.setdefault(item.note_id, []).append(_serialize_item(item))
return result
def _attachment_url(note_id, att_id) -> str: def _attachment_url(note_id, att_id) -> str:
@@ -108,7 +108,8 @@ async def _serialize_note(db, note: Note) -> dict:
data = note.serialize() data = note.serialize()
labels = await _labels_for_notes(db, [note.id]) labels = await _labels_for_notes(db, [note.id])
data["labels"] = labels.get(note.id, []) data["labels"] = labels.get(note.id, [])
data["items"] = items_of(note.body) items = await _items_for_notes(db, [note.id])
data["items"] = items.get(note.id, [])
attachments = await _attachments_for_notes(db, [note.id]) attachments = await _attachments_for_notes(db, [note.id])
data["attachments"] = attachments.get(note.id, []) data["attachments"] = attachments.get(note.id, [])
previews = await _previews_for_notes(db, [note.id]) previews = await _previews_for_notes(db, [note.id])
@@ -119,14 +120,14 @@ async def _serialize_note(db, note: Note) -> dict:
async def _serialize_notes(db, notes: list) -> list: async def _serialize_notes(db, notes: list) -> list:
ids = [n.id for n in notes] ids = [n.id for n in notes]
labels_map = await _labels_for_notes(db, ids) labels_map = await _labels_for_notes(db, ids)
items_map = await _items_for_notes(db, ids)
attach_map = await _attachments_for_notes(db, ids) attach_map = await _attachments_for_notes(db, ids)
preview_map = await _previews_for_notes(db, ids) preview_map = await _previews_for_notes(db, ids)
out = [] out = []
for n in notes: for n in notes:
data = n.serialize() data = n.serialize()
data["labels"] = labels_map.get(n.id, []) data["labels"] = labels_map.get(n.id, [])
data["items"] = items_of(n.body) data["items"] = items_map.get(n.id, [])
data["attachments"] = attach_map.get(n.id, []) data["attachments"] = attach_map.get(n.id, [])
data["previews"] = preview_map.get(n.id, []) data["previews"] = preview_map.get(n.id, [])
out.append(data) out.append(data)
+2
View File
@@ -32,6 +32,7 @@ from .db import session_scope
from .models.label import NoteLabel from .models.label import NoteLabel
from .models.note import Note from .models.note import Note
from .models.note_attachment import NoteAttachment from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem
from .models.note_link_preview import NoteLinkPreview from .models.note_link_preview import NoteLinkPreview
from .models.note_revision import NoteRevision from .models.note_revision import NoteRevision
from .settings import get_setting from .settings import get_setting
@@ -84,6 +85,7 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
# place would make the note reappear whole on the next sweep. # place would make the note reappear whole on the next sweep.
logger.warning("couldn't remove attachment file %s during purge", a.path, exc_info=True) logger.warning("couldn't remove attachment file %s during purge", a.path, exc_info=True)
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id)) await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
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))
-55
View File
@@ -1,55 +0,0 @@
"""When a body change is worth keeping a version of.
A note's body used to snapshot into `note_revisions` on EVERY write, which made a
write expensive — and the clients compensated by writing as rarely as they could
get away with, saving only when an editor closed. That is durability paying for
version history: a crash mid-session lost everything typed, so that the revision
list would stay tidy. The safety property is worth more than the feature it was
subsidising.
The rule here breaks that trade. A body change earns a snapshot only if it is the
first one of an editing session, so a client may write as often as it likes.
Session granularity falls out of the window rather than being declared. A snapshot
stores the body as it was BEFORE the edit, so the first write of a sitting captures
the note as you found it and every write after it inside the window adds nothing —
one revision per sitting, with no "commit" flag for a client to send and no wire
surface to carry it. That last part is why this is a time rule and not a protocol
one: `sync.py` applies pushed bodies through the same check, so a client autosaving
every second cannot make the server snapshot every second either.
Deliberately NOT applied to restoring a revision. That is a considered act rather
than a keystroke, and it snapshots unconditionally so restoring is itself undoable.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from .models.note_revision import NoteRevision
# How long one editing session is assumed to last. A constant rather than a setting:
# it is not a preference anyone holds, and the value only has to be longer than a
# sitting and shorter than the gap between two of them. Promote it to the settings
# registry (rule 25) if that ever stops being true.
REVISION_WINDOW_MINUTES = 10
async def should_snapshot(db, note_id: uuid.UUID, old_body: str, new_body: str) -> bool:
"""Whether `old_body` should be kept as a revision before `new_body` replaces it.
False when the text did not actually change — re-saving identical bytes is not a
version of anything — and False when this note already has a revision from the
current session.
"""
if old_body == new_body:
return False
cutoff = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES)
recent = await db.scalar(
select(NoteRevision.id)
.where(NoteRevision.note_id == note_id, NoteRevision.created_at >= cutoff)
.limit(1)
)
return recent is None
+50 -17
View File
@@ -24,8 +24,8 @@ from .db import session_scope
from .labeling import reconcile_manual_labels, resolve_owned_label_ids from .labeling import reconcile_manual_labels, resolve_owned_label_ids
from .models.label import Label, NoteLabel from .models.label import Label, NoteLabel
from .models.note import Note from .models.note import Note
from .models.note_item import NoteItem
from .models.note_revision import NoteRevision from .models.note_revision import NoteRevision
from .revisions import should_snapshot
from .notes import ( from .notes import (
_reconcile_tags, _reconcile_tags,
_serialize_notes, _serialize_notes,
@@ -62,8 +62,8 @@ MAX_PUSH = 1000 # per-batch change cap
# #
# One bump for the pair: they landed in the same protocol generation, and nothing ever # One bump for the pair: they landed in the same protocol generation, and nothing ever
# ran against a half-applied v2. # ran against a half-applied v2.
SYNC_PROTOCOL_VERSION = 3 SYNC_PROTOCOL_VERSION = 2
MIN_CLIENT_PROTOCOL_VERSION = 3 MIN_CLIENT_PROTOCOL_VERSION = 2
# Named capabilities beyond the base protocol. An ADDITIVE change earns a name # Named capabilities beyond the base protocol. An ADDITIVE change earns a name
# here rather than a min-version bump, so a newer client meeting an older server # here rather than a min-version bump, so a newer client meeting an older server
@@ -212,12 +212,49 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
note.position = ch["position"] note.position = ch["position"]
# `_first_item_text` and `_apply_note_items` lived here until M304. Both existed for def _first_item_text(ch: dict) -> str:
# one reason — a checklist was a table beside the body — and both are gone with it. A """The first non-blank checklist item in a pushed change, or "".
# pushed change carries its items as `- [ ] ` lines inside `body`, so applying them is
# applying the body, and naming the note is reading its first line. A client that still Read straight from the payload rather than the database because the note's name is
# sends an `items` array is a v2 client, and the version floor below turns it away computed BEFORE `_apply_note_items` has written anything — and a note whose body is
# before any of this runs. 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:
"""Replace the note's checklist items with the client's (items sync inline).
Applies to ANY note. This used to delete every item when the note wasn't
`kind == "list"`, which was survivable only because nothing could produce a note
holding both a body and items. M13 makes that the normal shape — a checklist is
something a note HAS, not something a note IS — and against that shape the old
guard was a data-loss path: the first sync after adding a checklist to a note
would have wiped it.
Removed ahead of the UI that can create the state, deliberately, so there is no
window in which the two disagree.
"""
items = ch.get("items")
if not isinstance(items, list):
# Absent means "not telling us", not "empty". A client that omits the key
# leaves what the server has; only an explicit [] clears it.
return
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
for pos, it in enumerate(items):
if not isinstance(it, dict):
continue
text = (it.get("text") or "").strip()
if text:
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None: async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
@@ -277,18 +314,14 @@ async def _apply_note(db, ch: dict) -> dict:
old_body = note.body old_body = note.body
_assign_note_fields(note, ch) _assign_note_fields(note, ch)
note.display_title = derive_display_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 body into history # Non-destructive LWW: snapshot the overwritten server body into history.
# subject to the same session window as a direct edit (revisions.should_snapshot). if not creating and note.body != old_body:
# This path is why the window is a time rule rather than a flag on the wire: a
# client autosaving every second pushes a body change every second, and without
# the check the SERVER would snapshot each one no matter how restrained the
# client's own store was being.
if not creating and await should_snapshot(db, note.id, old_body, note.body):
db.add(NoteRevision(note_id=note.id, 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 _reconcile_tags(db, note) await _reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch) await _apply_note_manual_labels(db, note, ch)
await db.flush() await db.flush()
+50 -109
View File
@@ -16,7 +16,6 @@ deployment has ever seen.
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timedelta, timezone
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -26,20 +25,19 @@ from thoughtsync import ratelimit
from thoughtsync.app import create_app from thoughtsync.app import create_app
from thoughtsync.db import dispose_engine, session_scope from thoughtsync.db import dispose_engine, session_scope
from thoughtsync.models.note import Note from thoughtsync.models.note import Note
from thoughtsync.models.note_item import NoteItem
from thoughtsync.models.user import User from thoughtsync.models.user import User
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.checklist import parse_items, set_item_checked
from thoughtsync.notes.helpers import derive_display_title from thoughtsync.notes.helpers import derive_display_title
from thoughtsync.models.note_link_preview import NoteLinkPreview from thoughtsync.models.note_link_preview import NoteLinkPreview
from thoughtsync.models.note_revision import NoteRevision from thoughtsync.sync import _apply_note_items
from thoughtsync.revisions import REVISION_WINDOW_MINUTES, should_snapshot
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate. # Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later. # RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, users" _TABLES = "notes, note_items, note_revisions, note_labels, note_link_previews, labels, users"
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -159,55 +157,65 @@ async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
assert body_only == 1 assert body_only == 1
async def test_a_note_keeps_its_prose_on_both_sides_of_its_list(db, owner): async def test_a_note_keeps_both_its_body_and_its_items(db, owner):
"""The shape M304 made expressible at all. """The shape M13 step 2 made normal: a note HAS a checklist, it isn't one."""
note = Note(owner_id=owner.id, body="weekend shop", display_title="weekend shop")
The old model could not hold this: a row had a position in a table and none in the
text, so a checklist could only ever render AFTER the body. Prose, list, prose is
the case that proves the storage changed, not just the styling.
"""
body = "weekend shop\n\n- [ ] milk\n- [x] eggs\n\nback before six"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note) db.add(note)
await db.flush()
db.add_all(
[
NoteItem(note_id=note.id, text="milk", position=0),
NoteItem(note_id=note.id, text="eggs", position=1),
]
)
await db.commit() await db.commit()
stored = await db.scalar(select(Note.body).where(Note.id == note.id)) items = (
assert [(i.text, i.checked) for i in parse_items(stored)] == [("milk", False), ("eggs", True)] await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id).order_by(NoteItem.position))
assert stored.splitlines()[0] == "weekend shop" ).all()
assert stored.splitlines()[-1] == "back before six" assert [i.text for i in items] == ["milk", "eggs"]
assert (await db.scalar(select(Note.body).where(Note.id == note.id))) == "weekend shop"
async def test_ticking_an_item_is_a_body_edit(db, owner): async def test_sync_no_longer_deletes_items_from_a_note_with_a_body(db, owner):
"""What replaced `_apply_note_items`: there is no separate thing left to apply. """The data-loss path step 2 removed, pinned against a real database.
The regression that function guarded against — a sync silently eating a checklist `_apply_note_items` used to delete every item when the note wasn't `kind = "list"`.
off a note that also had a body — cannot recur, because there is nothing to delete. Nothing can produce that state any more, but this is the regression that would
A pushed body either has the lines or it does not. have silently eaten a checklist, and it deserves a test that would catch its
return.
""" """
body = "packing\n\n- [ ] socks" note = Note(owner_id=owner.id, body="packing", display_title="packing")
note = Note(owner_id=owner.id, body=body, display_title="packing")
db.add(note) db.add(note)
await db.flush()
db.add(NoteItem(note_id=note.id, text="socks", position=0))
await db.commit() await db.commit()
note.body = set_item_checked(note.body, 0, True) # A change that says nothing about items must LEAVE them alone — absent means
# "not telling us", not "empty".
await _apply_note_items(db, note, {"body": "packing"})
await db.commit()
assert (await db.scalar(select(NoteItem.text).where(NoteItem.note_id == note.id))) == "socks"
# An explicit list replaces them.
await _apply_note_items(db, note, {"items": [{"text": "charger", "checked": True}]})
await db.commit()
rows = (await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id))).all()
assert [(r.text, r.checked) for r in rows] == [("charger", True)]
async def test_a_note_with_only_items_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe until step 2 closed it."""
note = Note(owner_id=owner.id, body="", display_title="")
db.add(note)
await db.flush()
db.add(NoteItem(note_id=note.id, text="milk", position=0))
await db.commit() await db.commit()
stored = await db.scalar(select(Note.body).where(Note.id == note.id)) first = await db.scalar(
assert stored == "packing\n\n- [x] socks" select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
assert parse_items(stored)[0].checked )
# The prose is untouched — a tick rewrites one line, not the note. note.display_title = derive_display_title(note.body, first)
assert stored.splitlines()[0] == "packing"
async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe, still closed — by a different
mechanism. There is no item table to fall back to any more; the name comes from
the first line with its marker stripped, because calling the note "- [ ] milk"
would show someone the storage instead of the note.
"""
body = "- [ ] milk\n- [ ] eggs"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
await db.commit() await db.commit()
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk" assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
@@ -406,70 +414,3 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
assert row["type"] == "int" assert row["type"] == "int"
assert row["minimum"] is not None and row["maximum"] is not None assert row["minimum"] is not None and row["maximum"] is not None
assert row["description"], f"{row['key']} has no description to explain itself" assert row["description"], f"{row['key']} has no description to explain itself"
async def _revision_count(db, note_id) -> int:
rows = (await db.scalars(select(NoteRevision.id).where(NoteRevision.note_id == note_id))).all()
return len(rows)
async def test_a_session_of_edits_costs_one_revision(db, owner):
"""The change that makes autosave affordable.
Version history used to snapshot on EVERY body write, so the clients saved as
rarely as they could — only when an editor closed — and a crash mid-session lost
everything typed. Durability was paying for history. Now a sitting earns one
revision no matter how many times it is written, so a client can write whenever
it likes.
"""
note = Note(owner_id=owner.id, body="one", display_title="one")
db.add(note)
await db.commit()
# A session's worth of autosaves.
for text_ in ("one two", "one two three", "one two three four"):
if await should_snapshot(db, note.id, note.body, text_):
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = text_
await db.commit()
assert await _revision_count(db, note.id) == 1
# And it is the body as it was BEFORE the sitting, not some midpoint — which is
# what makes one-per-session the useful granularity rather than an arbitrary one.
kept = (await db.scalars(select(NoteRevision.body).where(NoteRevision.note_id == note.id))).all()
assert kept == ["one"]
async def test_rewriting_the_same_text_is_not_a_version(db, owner):
note = Note(owner_id=owner.id, body="unchanged", display_title="unchanged")
db.add(note)
await db.commit()
assert await should_snapshot(db, note.id, note.body, "unchanged") is False
assert await _revision_count(db, note.id) == 0
async def test_a_later_sitting_earns_its_own_revision(db, owner):
"""The window has to REOPEN, or a note edited daily would keep only its first
version forever — which would be a worse history than the one we replaced."""
note = Note(owner_id=owner.id, body="today", display_title="today")
db.add(note)
await db.flush()
# A revision from longer ago than one session: the clock is not mocked, the row
# is simply written with an older timestamp, which is what the query reads.
stale = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES + 1)
db.add(NoteRevision(note_id=note.id, body="yesterday", created_at=stale))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "tomorrow") is True
async def test_a_revision_inside_the_window_blocks_another(db, owner):
note = Note(owner_id=owner.id, body="draft", display_title="draft")
db.add(note)
await db.flush()
db.add(NoteRevision(note_id=note.id, body="earlier", created_at=datetime.now(timezone.utc)))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "draft revised") is False
+12 -176
View File
@@ -5,21 +5,12 @@ import pytest
from thoughtsync.app import create_app from thoughtsync.app import create_app
from thoughtsync.common import coerce_bool, parse_dt from thoughtsync.common import coerce_bool, parse_dt
from thoughtsync.models.note import NOTE_COLORS, Note from thoughtsync.models.note import NOTE_COLORS, Note
from thoughtsync.notes.checklist import (
append_item,
parse_items,
remove_item,
set_item_checked,
set_item_text,
strip_marker,
)
from thoughtsync.unfurl_queue import detect_urls from thoughtsync.unfurl_queue import detect_urls
from thoughtsync.notes import ( from thoughtsync.notes import (
_attachment_ext, _attachment_ext,
_header_filename, _header_filename,
_keep_spec, _keep_spec,
_native_spec, _native_spec,
_note_markdown,
_safe_filename, _safe_filename,
_slugify, _slugify,
_usec_to_dt, _usec_to_dt,
@@ -51,7 +42,7 @@ def test_all_note_routes_registered(app):
"reorder_notes", "create_note", "reorder_notes", "create_note",
"get_note", "update_note", "list_revisions", "restore_revision", "get_note", "update_note", "list_revisions", "restore_revision",
"set_note_labels", "add_item", "update_item", "delete_item", "set_note_labels", "add_item", "update_item", "delete_item",
"upload_attachment", "get_attachment", "reorder_items", "upload_attachment", "get_attachment",
"delete_attachment", "unfurl_link", "delete_preview", "trash_note", "delete_attachment", "unfurl_link", "delete_preview", "trash_note",
"restore_note", "delete_note", "restore_note", "delete_note",
) )
@@ -140,32 +131,27 @@ def test_derive_display_title_is_the_first_body_line():
assert derive_display_title("\n \nreal line\nmore") == "real line" assert derive_display_title("\n \nreal line\nmore") == "real line"
def test_derive_display_title_names_a_list_only_note(): def test_derive_display_title_falls_back_to_the_first_item():
# The same property the old `first_item` fallback protected — a note that is only # What step 2 bought: a note that is only a checklist still has a name. Without
# a checklist still has a name — reached a different way. Items ARE body lines now # this it would have none at all, which is why the title could not go first.
# (M304), so the first one is simply the first line, with its marker stripped: assert derive_display_title("", "milk") == "milk"
# calling the note "- [ ] milk" would show someone the storage instead of the note. assert derive_display_title(" \n ", " eggs ") == "eggs"
assert derive_display_title("- [ ] milk\n- [ ] eggs") == "milk" # The body still wins when it has anything to say.
assert derive_display_title(" * [x] eggs ") == "eggs" assert derive_display_title("shopping", "milk") == "shopping"
# Prose still wins when it comes first, because it IS the first line.
assert derive_display_title("shopping\n\n- [ ] milk") == "shopping"
# An EMPTY item does not name the note "" — a half-typed list still has a name.
assert derive_display_title("- [ ]\n- [ ] eggs") == "eggs"
def test_derive_display_title_empty(): def test_derive_display_title_empty():
assert derive_display_title(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) == ""
# A list of nothing but empty items is still a note with no name. assert derive_display_title(" \n ", " ") == ""
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(long) == "x" * 200 assert derive_display_title(long) == "x" * 200
# A first line that happens to be an item is capped on the same rule. # the item fallback is capped on the same rule
assert derive_display_title(f"- [ ] {long}") == "x" * 200 assert derive_display_title("", long) == "x" * 200
def test_parse_tags(): def test_parse_tags():
@@ -420,153 +406,3 @@ def test_detect_urls_ignores_non_http():
assert detect_urls("ftp://example.com and mailto:a@b.c and bare example.com") == [] assert detect_urls("ftp://example.com and mailto:a@b.c and bare example.com") == []
assert detect_urls(None) == [] assert detect_urls(None) == []
assert detect_urls("") == [] assert detect_urls("") == []
# --- checklist items: the body IS the checklist (M304) -----------------------
#
# The same table of cases as core/src/local/derive.rs. Deliberately duplicated
# rather than shared: the point of three implementations is that each is checked
# against the same grammar, and a test that only ran once would not catch the two
# drifting apart.
def test_parse_items_reads_a_list_out_of_prose():
body = "shopping\n\n- [ ] milk\n- [x] eggs"
assert [(i.text, i.checked) for i in parse_items(body)] == [("milk", False), ("eggs", True)]
def test_parse_items_between_paragraphs():
# The case a side table could not express, which is the whole reason for M304.
assert [i.text for i in parse_items("before\n- [ ] middle\nafter")] == ["middle"]
@pytest.mark.parametrize(
"body",
[
"-[ ] no space after the dash",
"- [] empty brackets",
"- [ ]no space after the brackets",
"- [y] not a mark",
"a [ ] mid sentence",
"[ ] no bullet at all",
],
)
def test_parse_items_rejects_near_misses(body):
assert parse_items(body) == []
def test_parse_items_accepts_star_bullets_and_indentation():
# `*` because markdown.ts already takes it for a plain bullet.
body = "* [ ] star\n - [x] indented"
assert [(i.text, i.checked) for i in parse_items(body)] == [("star", False), ("indented", True)]
def test_an_empty_item_is_still_an_item():
# What pressing Enter on a list leaves behind.
assert [i.text for i in parse_items("- [ ]")] == [""]
assert [i.text for i in parse_items("- [ ] ")] == [""]
def test_uppercase_x_parses_and_normalises_on_rewrite():
assert parse_items("- [X] done")[0].checked
assert set_item_checked("- [X] done", 0, True) == "- [x] done"
def test_rewriters_preserve_indent_bullet_and_neighbours():
assert set_item_checked(" * [ ] milk", 0, True) == " * [x] milk"
assert set_item_text("- [x] old", 0, "new") == "- [x] new"
assert remove_item("keep\n- [ ] drop\n- [ ] stay", 0) == "keep\n- [ ] stay"
# Addressed by ITEM, not by line.
assert set_item_checked("note\n- [ ] a\nprose\n- [ ] b", 1, True) == "note\n- [ ] a\nprose\n- [x] b"
def test_a_stale_index_does_nothing():
# The index comes from a client that may be a moment behind. A late request
# should be inert, not a 500.
body = "- [ ] only"
assert set_item_checked(body, 7, True) == body
assert remove_item(body, 7) == body
assert set_item_text(body, 7, "x") == body
def test_a_plain_body_is_returned_unchanged():
body = "just prose\nwith two lines"
assert set_item_checked(body, 0, True) == body
assert remove_item(body, 0) == body
def test_append_item_spacing():
# Prose, blank line, list — the layout _note_markdown has always exported, and
# what the migration folds existing rows into.
assert append_item("a note", "milk") == "a note\n\n- [ ] milk"
# Nothing between consecutive items.
assert append_item("a note\n\n- [ ] milk", "eggs") == "a note\n\n- [ ] milk\n- [ ] eggs"
# A list-only note starts at the first line.
assert append_item("", "milk") == "- [ ] milk"
assert append_item("\n\n", "milk") == "- [ ] milk"
# Carries state, which is what the migrations need of it.
assert append_item("", "done", True) == "- [x] done"
def test_strip_marker():
assert strip_marker("- [x] milk") == "milk"
assert strip_marker("just prose") == "just prose"
assert strip_marker("- [ ]") == ""
def test_the_migration_folds_exactly_like_the_app():
"""0027 inlines its own copy of append_item, deliberately — a migration has to keep
producing what it produced the day it ran, so it must not follow the app if the
app's spacing ever changes. This is what keeps the copy honest until then."""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0027_checklist_items_into_body.py"
spec = importlib.util.spec_from_file_location("_m0027", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for body, text, checked in [
("a note", "milk", False),
("a note\n\n- [ ] milk", "eggs", True),
("", "milk", False),
("\n\n", "milk", False),
("prose\n", " padded ", True),
]:
assert module._append_item(body, text, checked) == append_item(body, text, checked)
# --- import/export: the body already carries the list ------------------------
def test_note_markdown_writes_a_checklist_once():
"""The export used to append the items after the body. The body IS them now
(M304), so the old branch would have doubled every checklist in an export — and
doubled it again on the next re-import."""
note = Note(
display_title="shopping",
body="shopping\n\n- [ ] milk\n- [x] eggs",
color="default",
pinned=False,
archived=False,
)
out = _note_markdown(note, [])
assert out.count("- [ ] milk") == 1
assert out.count("- [x] eggs") == 1
# And in place, under the note's own first line rather than in a block of its own.
assert out.rstrip().endswith("shopping\n\n- [ ] milk\n- [x] eggs")
def test_native_spec_of_a_current_export_folds_nothing():
# Today's export carries no `items` key, because the body has the lines. An empty
# list is what stops _insert_note folding them in a second time.
assert _native_spec({"body": "a\n\n- [ ] milk"})["items"] == []
def test_native_spec_of_a_pre_m304_export_still_carries_its_items():
# An export taken BEFORE this milestone has a body with no task lines and a
# separate items array. Importing one has to put the checklist back — which is
# the same fold the Keep importer does, and the reason _insert_note still accepts
# items at all.
old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]}
assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}]