Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3e29e4f6 | ||
|
|
867405fae2 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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", [¬e_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
@@ -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(¬e.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(¬e.body);
|
// After the items, because a body-only-empty note is named by its first one.
|
||||||
|
note.display_title = display_title(¬e.body, ¬e.items);
|
||||||
Ok(note)
|
Ok(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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}]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user