Compare commits
32
Commits
v26.08.23
...
ee47a61270
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee47a61270 | ||
|
|
68f851110f | ||
|
|
96a6f6e691 | ||
|
|
44b3bcb2b2 | ||
|
|
a45a44ef11 | ||
|
|
56264a9220 | ||
|
|
a88f7c2dd0 | ||
|
|
32ec29fc4a | ||
|
|
ae17b8a8e7 | ||
|
|
eeca4d48c2 | ||
|
|
b2435d97b6 | ||
|
|
9a3c4ec377 | ||
|
|
315c5f19e6 | ||
|
|
1a66d9c3a8 | ||
|
|
77b1a87712 | ||
|
|
68b2a5dc8d | ||
|
|
3cab054684 | ||
|
|
fe1f72ae1b | ||
|
|
761c3b5e82 | ||
|
|
32dafca148 | ||
|
|
668f7faf03 | ||
|
|
d0e3e48943 | ||
|
|
1045db318b | ||
|
|
65af37d159 | ||
|
|
8257e1035c | ||
|
|
bca9e16bd0 | ||
|
|
9ea2a2f9b6 | ||
|
|
ce6a1093a3 | ||
|
|
2707054563 | ||
|
|
24685556b7 | ||
|
|
50e2d308ea | ||
|
|
77c5422951 |
@@ -193,8 +193,7 @@ jobs:
|
||||
# them ever executed by CI — and the schema the migrations build had never been
|
||||
# checked against the models that read it.
|
||||
#
|
||||
# Runs for visibility and does NOT gate the build, matching the `test` lane and
|
||||
# FabledScribe's equivalent job.
|
||||
# Gates the build, along with every other lane — see the `build` job's `needs`.
|
||||
#
|
||||
# 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
|
||||
@@ -261,10 +260,16 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
# Build gates on lint + typecheck. The `test` job runs in parallel for
|
||||
# visibility but does not block dev image builds (DB-backed integration
|
||||
# testing happens against the dev image manually, not on every push).
|
||||
needs: [gate, typecheck, lint]
|
||||
# Every lane gates the build. This once stopped at lint + typecheck, on the
|
||||
# reasoning that DB-backed testing happened manually against the dev image
|
||||
# rather than on every push — true until 6f21db8 added the integration lane,
|
||||
# and false since.
|
||||
#
|
||||
# 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'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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"])
|
||||
@@ -7,6 +7,9 @@
|
||||
this permission never exercised.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Only to answer "is this connection metered?" before the app downloads its own
|
||||
update in the background. Normal permission, no prompt, no location. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!--
|
||||
Four more permissions are NOT declared here and still reach the merged
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
@@ -63,6 +64,22 @@ object AppUpdate {
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/** Where a download goes: app-private, so no storage permission is involved. */
|
||||
/**
|
||||
* Whether this is a network to spend fifty-odd megabytes on without being asked.
|
||||
*
|
||||
* The update fetches itself in the background once one is found, and doing that
|
||||
* over mobile data is a bill nobody agreed to. On a metered link the update is
|
||||
* still FOUND and still nags — pressing Install downloads it then, which is a
|
||||
* choice rather than a surprise.
|
||||
*
|
||||
* A missing ConnectivityManager reads as metered: the cautious answer is the one
|
||||
* that costs nothing.
|
||||
*/
|
||||
fun onUnmeteredNetwork(context: Context): Boolean {
|
||||
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
return !manager.isActiveNetworkMetered
|
||||
}
|
||||
|
||||
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,8 +25,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardUpdate
|
||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||
@@ -141,14 +141,13 @@ private fun App(
|
||||
val sync: SyncViewModel =
|
||||
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
||||
|
||||
// Sheet and screen visibility are view STATE, not view-model state: they are
|
||||
// about what is on the display, and nothing in the store cares.
|
||||
// Screen visibility is view STATE, not view-model state: it is about what is on
|
||||
// the display, and nothing in the store cares. Saveable so a rotation does not
|
||||
// close it.
|
||||
//
|
||||
// Saveable, though: `remember` alone meant rotating the phone closed whatever
|
||||
// was open and took the half-written note in the capture sheet with it. The
|
||||
// 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) }
|
||||
// The capture sheet used to keep its own flag here too. It is gone: the + button
|
||||
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||
// same surface with the same toolbar.
|
||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||
@@ -156,6 +155,7 @@ private fun App(
|
||||
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||
|
||||
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
||||
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
|
||||
|
||||
val editing = board.state.editing
|
||||
val screen =
|
||||
@@ -192,6 +192,7 @@ private fun App(
|
||||
NoteEditorScreen(
|
||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||
note = requireNotNull(editing) { "the editor screen needs a note" },
|
||||
sessionKey = board.state.editingSession,
|
||||
labels = board.state.labels,
|
||||
saving = board.state.saving,
|
||||
error = board.state.error,
|
||||
@@ -218,20 +219,24 @@ private fun App(
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
onSearch = board::search,
|
||||
onCompose = { composing = true },
|
||||
onCompose = board::compose,
|
||||
onToggleItem = board::toggleItem,
|
||||
// Null unless there is genuinely something to say — the board is
|
||||
// handed a decision, not a state to interpret.
|
||||
update =
|
||||
update.state.available
|
||||
?.takeIf { update.state.nagging }
|
||||
?.let {
|
||||
BoardUpdate(
|
||||
version = it.version,
|
||||
ready = update.state.ready,
|
||||
busy = update.state.busy,
|
||||
onInstall = update::downloadAndInstall,
|
||||
onDismiss = update::dismissNag,
|
||||
)
|
||||
},
|
||||
onDismissError = board::dismissError,
|
||||
)
|
||||
|
||||
if (composing) {
|
||||
ComposeSheet(
|
||||
saving = board.state.saving,
|
||||
onDismiss = { composing = false },
|
||||
onSave = { content ->
|
||||
board.create(content)
|
||||
composing = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +287,36 @@ private fun ReminderAlarms(core: ThoughtSync) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Looking for an app update without being asked.
|
||||
*
|
||||
* Until this existed, `check()` had exactly one caller: a button on the sync screen.
|
||||
* So a new build was found only by someone who went looking for one, and the operator
|
||||
* had to remember to go looking — which is the same as not being told.
|
||||
*
|
||||
* On coming forward rather than on a timer: it is the moment the person is present,
|
||||
* and the view model rate-limits so flicking between two apps is not a re-check.
|
||||
* Unlinked devices are skipped entirely — updates come from a linked server, and
|
||||
* there is nothing to ask.
|
||||
*/
|
||||
@Composable
|
||||
private fun AutomaticUpdate(
|
||||
linked: Boolean,
|
||||
onCheck: () -> Unit,
|
||||
) {
|
||||
var wanted by remember { mutableStateOf(false) }
|
||||
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
|
||||
|
||||
LaunchedEffect(wanted, linked) {
|
||||
if (!wanted || !linked) return@LaunchedEffect
|
||||
// Consumed here, so this fires once per trip to the foreground however many
|
||||
// times the effect restarts. There is no suspension point before the call, so
|
||||
// the block completes before the recomposition that would cancel it.
|
||||
wanted = false
|
||||
onCheck()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncing without being asked.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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,
|
||||
) {
|
||||
// Material sizes every interactive component to a 48dp touch target, and on a
|
||||
// checklist that IS the row height — which is why six items filled a phone screen
|
||||
// even after the field's own padding came off.
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
|
||||
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 touch target for a checklist row's controls.
|
||||
*
|
||||
* Material's floor is 48dp and this is deliberately under it. That floor is sized for
|
||||
* a control somebody has to find; a checklist box sits in a predictable column with an
|
||||
* identical box directly above and below, and the cost of a near miss is ticking the
|
||||
* neighbouring item — visible, and undone by tapping again. Trading twelve of those
|
||||
* dp for a list that fits on a screen is what was asked for, twice.
|
||||
*/
|
||||
private val ROW_TOUCH = 36.dp
|
||||
|
||||
/**
|
||||
* 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,6 +64,8 @@ fun BoardScreen(
|
||||
onOpenSync: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
update: BoardUpdate?,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -117,6 +119,18 @@ fun BoardScreen(
|
||||
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
||||
}
|
||||
|
||||
// Below the failures and above the notes: an update is worth saying,
|
||||
// and never worth saying before a note failed to save.
|
||||
update?.let {
|
||||
UpdateBanner(
|
||||
version = it.version,
|
||||
ready = it.ready,
|
||||
busy = it.busy,
|
||||
onInstall = it.onInstall,
|
||||
onDismiss = it.onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
// Only where someone is already thinking about reminders. On the
|
||||
// main board it would nag people who have never set one.
|
||||
if (state.destination == Destination.Reminders) ReminderNotice()
|
||||
@@ -140,7 +154,12 @@ fun BoardScreen(
|
||||
when {
|
||||
state.loading -> LoadingBoard()
|
||||
state.notes.isEmpty() -> EmptyBoard(state)
|
||||
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
|
||||
else ->
|
||||
NoteBoard(
|
||||
notes = state.notes,
|
||||
onOpenNote = onOpenNote,
|
||||
onToggleItem = onToggleItem,
|
||||
)
|
||||
}
|
||||
// `PullToRefreshBox` would be less code, but it takes no
|
||||
// `enabled`, so the modifier and the indicator are wired by
|
||||
@@ -181,6 +200,26 @@ data class BoardSync(
|
||||
val onDismissError: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The waiting app update, or null when there is nothing to say.
|
||||
*
|
||||
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
|
||||
* `version` and a pair of booleans as positional arguments could be swapped with
|
||||
* nothing to catch it.
|
||||
*
|
||||
* Null covers every reason there is nothing to show — unlinked, up to date, already
|
||||
* dismissed for this sitting, mid-install — so the board never has to know which.
|
||||
*/
|
||||
data class BoardUpdate(
|
||||
val version: String,
|
||||
/** Already fetched, so Install is one tap rather than a wait. */
|
||||
val ready: Boolean,
|
||||
/** A check, fetch or install is in flight. */
|
||||
val busy: Boolean,
|
||||
val onInstall: () -> Unit,
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* A search field IS the top bar, following the phone convention rather than the
|
||||
* desktop's title-plus-sidebar.
|
||||
@@ -323,6 +362,7 @@ private fun DrawerRow(
|
||||
private fun NoteBoard(
|
||||
notes: List<Note>,
|
||||
onOpenNote: (Note) -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
) {
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
|
||||
@@ -336,7 +376,11 @@ private fun NoteBoard(
|
||||
// rebuilding them — and so a newly captured note slides in instead of
|
||||
// making every card below it flicker.
|
||||
items(items = notes, key = { it.id }) { note ->
|
||||
NoteCard(note = note, onOpen = { onOpenNote(note) })
|
||||
NoteCard(
|
||||
note = note,
|
||||
onOpen = { onOpenNote(note) },
|
||||
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,16 @@ data class BoardState(
|
||||
* has to re-query to see its own change.
|
||||
*/
|
||||
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. */
|
||||
val searching: Boolean get() = query.isNotBlank()
|
||||
@@ -188,44 +198,6 @@ 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 ──────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -238,12 +210,111 @@ class BoardViewModel(
|
||||
fun openNoteById(id: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
|
||||
.onSuccess { state = state.copy(editing = it) }
|
||||
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
|
||||
}
|
||||
}
|
||||
|
||||
fun openNote(note: Note) {
|
||||
state = state.copy(editing = note)
|
||||
state = state.copy(editing = note, editingSession = state.editingSession + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,13 +339,18 @@ class BoardViewModel(
|
||||
note: Note,
|
||||
action: EditorAction,
|
||||
) {
|
||||
if (note.id == DRAFT_ID) {
|
||||
onDraftAction(note, action)
|
||||
return
|
||||
}
|
||||
val id = note.id
|
||||
when (action) {
|
||||
EditorAction.Close -> state = state.copy(editing = null)
|
||||
EditorAction.DismissError -> dismissError()
|
||||
|
||||
// Saved on close rather than per keystroke, so a session of typing
|
||||
// costs one write and one revision snapshot.
|
||||
// Sent on an idle debounce while typing, and again on close. Writing
|
||||
// this often is affordable because a body write no longer snapshots a
|
||||
// revision — the core keeps one per editing session, not one per save.
|
||||
is EditorAction.SaveText ->
|
||||
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||
|
||||
@@ -302,20 +378,6 @@ class BoardViewModel(
|
||||
null
|
||||
}
|
||||
|
||||
// An empty first item: the checklist editor appears the moment the note
|
||||
// has one, and an empty row is what someone can type straight into.
|
||||
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
|
||||
|
||||
is EditorAction.AddItem ->
|
||||
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
|
||||
mutate { it.addItem(id, text) }
|
||||
}
|
||||
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.CreateLabel ->
|
||||
@@ -351,9 +413,10 @@ class BoardViewModel(
|
||||
/**
|
||||
* The one path every store mutation takes.
|
||||
*
|
||||
* Each core mutation returns the reloaded note, which goes straight into
|
||||
* [BoardState.editing] so an open editor shows its own change without a
|
||||
* re-query. The BOARD list is then reloaded rather than patched in place:
|
||||
* Each core mutation returns the reloaded note, which refreshes
|
||||
* [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
|
||||
* 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
|
||||
* 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.
|
||||
@@ -389,7 +452,12 @@ class BoardViewModel(
|
||||
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||
state.copy(
|
||||
notes = notes,
|
||||
editing = if (closeEditor) null else updated ?: state.editing,
|
||||
// Only REFRESHES an open editor; it must never open one.
|
||||
// `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,
|
||||
error = null,
|
||||
)
|
||||
@@ -402,6 +470,21 @@ 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() {
|
||||
state = state.copy(error = null)
|
||||
}
|
||||
@@ -447,3 +530,33 @@ private fun draft(content: String): NoteDraft =
|
||||
// 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.
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
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,15 +29,6 @@ sealed interface EditorAction {
|
||||
val color: String,
|
||||
) : 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(
|
||||
val pinned: Boolean,
|
||||
) : EditorAction
|
||||
@@ -52,23 +43,12 @@ sealed interface EditorAction {
|
||||
|
||||
data object DeleteForever : EditorAction
|
||||
|
||||
data class AddItem(
|
||||
val text: String,
|
||||
) : EditorAction
|
||||
|
||||
data class SetItemChecked(
|
||||
val itemId: String,
|
||||
val checked: Boolean,
|
||||
) : EditorAction
|
||||
|
||||
data class SetItemText(
|
||||
val itemId: String,
|
||||
val text: String,
|
||||
) : EditorAction
|
||||
|
||||
data class DeleteItem(
|
||||
val itemId: String,
|
||||
) : EditorAction
|
||||
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
|
||||
// body, so adding, renaming, ticking or deleting one is editing text — which the
|
||||
// editor already does, through SaveText, with the same autosave and the same
|
||||
// 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
|
||||
// person was typing in.
|
||||
|
||||
/**
|
||||
* The note's MANUAL labels, replacing whatever was there.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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)))
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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,5 +1,6 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -8,23 +9,31 @@ 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.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -39,7 +48,19 @@ import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
/**
|
||||
* The editor's action bar, at the bottom where a thumb already is.
|
||||
* The editor's action bar, along the top of the surface.
|
||||
*
|
||||
* 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
|
||||
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
|
||||
@@ -54,57 +75,189 @@ import com.fabledsword.thoughtsync.core.Note
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditorBottomBar(
|
||||
fun EditorTopBar(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
tint: NoteTint,
|
||||
onClose: () -> Unit,
|
||||
onStartChecklist: () -> Unit,
|
||||
onPicker: (Picker) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
BottomAppBar(containerColor = tint.background(dark)) {
|
||||
if (!readOnly) {
|
||||
// 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.
|
||||
IconButton(onClick = { onPicker(Picker.COLOR) }) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH_DOT)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.border(dark), CircleShape),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||
TopAppBar(
|
||||
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.Filled.Notifications,
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.editor_back),
|
||||
)
|
||||
}
|
||||
// Adds the first checklist item, which is what makes the checklist
|
||||
// editor appear. Hidden once the note already has one — there is nothing
|
||||
// left to add that the checklist's own "+" row doesn't do better.
|
||||
if (note.items.isEmpty()) {
|
||||
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
|
||||
},
|
||||
actions = {
|
||||
if (!readOnly) {
|
||||
// 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.
|
||||
IconButton(onClick = { onPicker(Picker.COLOR) }) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH_DOT)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.border(dark), CircleShape),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||
Icon(
|
||||
Icons.Filled.Notifications,
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
)
|
||||
}
|
||||
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
||||
// a checklist is text now (M304), so there is no section to be
|
||||
// already-showing and no reason a second list cannot start further
|
||||
// down the same note.
|
||||
IconButton(onClick = onStartChecklist) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
onPicker = onPicker,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f))
|
||||
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
onPicker = onPicker,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
onAction = onAction,
|
||||
/**
|
||||
* 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,24 +15,26 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
||||
import com.fabledsword.thoughtsync.core.BodyItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||
import com.fabledsword.thoughtsync.core.checklistItems
|
||||
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
note: Note,
|
||||
onOpen: () -> Unit,
|
||||
onToggleItem: (Int, Boolean) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
@@ -53,21 +55,12 @@ fun NoteCard(
|
||||
// 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).
|
||||
if (note.body.isNotBlank()) {
|
||||
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)
|
||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||
}
|
||||
|
||||
// A note with no body and no items still has to occupy the board legibly —
|
||||
// otherwise it reads as a rendering bug.
|
||||
if (note.body.isBlank() && note.items.isEmpty()) {
|
||||
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||
// it reads as a rendering bug.
|
||||
if (note.body.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -88,46 +81,100 @@ 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
|
||||
private fun Checklist(items: List<ChecklistItem>) {
|
||||
private fun NoteBody(
|
||||
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)) {
|
||||
items.take(MAX_CHECKLIST_ROWS).forEach { item ->
|
||||
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 = if (item.checked) "☑" else "☐",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = item.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||
color =
|
||||
if (item.checked) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
val hidden = items.size - MAX_CHECKLIST_ROWS
|
||||
if (hidden > 0) {
|
||||
if (lines.size > MAX_PREVIEW_LINES) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
text = "…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
Text(
|
||||
text = if (item.checked) "☑" else "☐",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = item.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||
color =
|
||||
if (item.checked) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = MAX_WRAPPED_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelChips(labels: List<NoteLabel>) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -182,7 +229,9 @@ private fun ReminderChip(
|
||||
}
|
||||
|
||||
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 val CARD_RADIUS = 12.dp
|
||||
private val CHIP_RADIUS = 6.dp
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
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.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.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.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.res.stringResource
|
||||
@@ -34,23 +32,31 @@ import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The note editor: a full screen, not a sheet.
|
||||
* The one writing surface: a new note and an existing one are the same screen.
|
||||
*
|
||||
* A sheet works for capture, where the board behind it is reassurance that the
|
||||
* thought landed somewhere. Editing is different — a sustained task with the
|
||||
* keyboard up — and a sheet would spend the whole time fighting the IME for the
|
||||
* bottom half of the display. Full screen also gives the actions a bottom bar,
|
||||
* which is where a thumb already is.
|
||||
* Shaped like the capture sheet it replaced — a rounded card that begins below the
|
||||
* status bar — so opening a note still reads as something rising over the board
|
||||
* rather than a place you navigated to. It is full height rather than a real
|
||||
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
|
||||
* negotiating with the IME for the bottom half of the display, and the swipe-down it
|
||||
* buys is a gesture back already does. The shape is what was worth keeping.
|
||||
*
|
||||
* The note's own colour paints the WHOLE screen rather than a card inside it, so
|
||||
* The note's own colour paints the WHOLE card rather than a panel inside it, so
|
||||
* 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)
|
||||
@Composable
|
||||
fun NoteEditorScreen(
|
||||
note: Note,
|
||||
sessionKey: Long,
|
||||
labels: List<Label>,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
@@ -59,11 +65,30 @@ fun NoteEditorScreen(
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
|
||||
// Keyed by note id: the editor is reused across notes, and without the key the
|
||||
// second note opened would show the first one's text.
|
||||
var body by remember(note.id) { mutableStateOf(note.body) }
|
||||
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
||||
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
||||
// needs a key — but a draft's id changes the moment it is first saved, and
|
||||
// re-keying on that would reset this state to whatever the store just returned,
|
||||
// throwing away every character typed during the write.
|
||||
//
|
||||
// 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
|
||||
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||
@@ -75,8 +100,8 @@ fun NoteEditorScreen(
|
||||
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
||||
// revision identical to the one before it.
|
||||
val flush = {
|
||||
if (!readOnly && body != note.body) {
|
||||
onAction(EditorAction.SaveText(body))
|
||||
if (!readOnly && bodyText != note.body) {
|
||||
onAction(EditorAction.SaveText(bodyText))
|
||||
}
|
||||
}
|
||||
val leave = {
|
||||
@@ -84,6 +109,32 @@ fun NoteEditorScreen(
|
||||
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)
|
||||
|
||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
||||
@@ -91,83 +142,115 @@ fun NoteEditorScreen(
|
||||
// is exactly the failure that makes someone stop trusting a notes app.
|
||||
FlushOnStop(flush)
|
||||
|
||||
Scaffold(
|
||||
containerColor = tint.background(dark),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = leave) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.editor_back),
|
||||
// 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(
|
||||
containerColor = tint.background(dark),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
topBar = {
|
||||
EditorTopBar(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
tint = tint,
|
||||
onClose = leave,
|
||||
onStartChecklist = {
|
||||
val (next, id) = blocks.plusTask()
|
||||
blocks = next
|
||||
focus = id
|
||||
},
|
||||
onPicker = { picker = it },
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
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 ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.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)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
// A failed save has to be visible HERE. The board renders the
|
||||
// same banner, but a write that fails while the editor is open
|
||||
// would otherwise report itself only after the user had already
|
||||
// left.
|
||||
error?.let { message ->
|
||||
ErrorBanner(
|
||||
message = message,
|
||||
onDismiss = { onAction(EditorAction.DismissError) },
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
EditorBottomBar(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
tint = tint,
|
||||
onPicker = { picker = it },
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
onAction = onAction,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
// A one-pixel line, not a spinner: a save slow enough to see is worth
|
||||
// showing, and one that isn't must not make the screen jump.
|
||||
if (saving) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// A failed save has to be visible HERE. The board renders the same
|
||||
// banner, but a write that fails while the editor is open would
|
||||
// otherwise report itself only after the user had already left.
|
||||
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
|
||||
// is nothing separate to type into and nothing rendered bolder than
|
||||
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
||||
// how the body is DRAWN — checklist items as boxes rather than as
|
||||
// the markup for boxes.
|
||||
BlockBody(
|
||||
blocks = blocks,
|
||||
readOnly = readOnly,
|
||||
focus = focus,
|
||||
onChange = { blocks = it },
|
||||
onFocus = { focus = it },
|
||||
)
|
||||
|
||||
// One field. A note is its body; its NAME is that body's first line, so
|
||||
// there is nothing separate to type into and nothing to render bolder
|
||||
// than the line beneath it (M13 steps 3 and 4).
|
||||
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
|
||||
// (M304) — rendering them again down here is what would put every
|
||||
// list on screen twice.
|
||||
|
||||
// Below the body, not instead of it, and only once the note has items —
|
||||
// the toolbar's add-checklist action is what puts the first one there.
|
||||
if (note.items.isNotEmpty()) {
|
||||
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
EditorReminderRow(
|
||||
at = at,
|
||||
recurrence = note.recurrence,
|
||||
readOnly = readOnly,
|
||||
onAction = onAction,
|
||||
)
|
||||
note.remindAt?.let { at ->
|
||||
EditorReminderRow(
|
||||
at = at,
|
||||
recurrence = note.recurrence,
|
||||
readOnly = readOnly,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,32 +328,16 @@ private fun EditorOverlays(
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's body field.
|
||||
* How long typing has to stop before the note is written.
|
||||
*
|
||||
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
|
||||
* the note's colour, and a filled field would draw a second surface over the first
|
||||
* 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.
|
||||
* Long enough that a normal sentence is one write, short enough that nothing
|
||||
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
|
||||
* cover the window between the last keystroke and this elapsing.
|
||||
*/
|
||||
@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 AUTOSAVE_IDLE_MS = 1_000L
|
||||
|
||||
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,6 +9,7 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldColors
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -20,12 +21,16 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
/**
|
||||
* A text field with no box around it.
|
||||
*
|
||||
* Every writing surface in the app — the capture sheet, the editor's title and
|
||||
* body, each checklist row — sits on a surface that already has its own edges and
|
||||
* its own colour. Material's filled field would draw a second, differently
|
||||
* coloured box inside the first, which makes writing a note look like filling in a
|
||||
* form. Stripping the container and the indicator in four places independently is
|
||||
* how they drift apart, so it happens once, here.
|
||||
* The search box, the label picker, the sync-pairing form: fields that sit on a
|
||||
* surface which already has its own edges and its own colour, where Material's filled
|
||||
* field would draw a second, differently coloured box inside the first. Stripping the
|
||||
* container and the indicator at each site 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
|
||||
* field read-only, and Material's disabled treatment would grey out text the user
|
||||
@@ -58,18 +63,26 @@ fun PlainTextField(
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
visualTransformation = visualTransformation,
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
// Full-strength, not Material's 38%-alpha disabled treatment: a
|
||||
// trashed note is rendered read-only through this field and its
|
||||
// text is meant to be READ, not visually retired.
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
),
|
||||
colors = plainFieldColors(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
// Full-strength, not Material's 38%-alpha disabled treatment: a trashed
|
||||
// note is rendered read-only through this field and its text is meant to
|
||||
// be READ, not visually retired.
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
@@ -67,9 +66,14 @@ 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. */
|
||||
fun isPast(raw: String): Boolean =
|
||||
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
|
||||
fun isPast(raw: String): Boolean {
|
||||
val at = epochMillis(raw) ?: return false
|
||||
return at < System.currentTimeMillis()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a timestamp is older than [minutes] ago — or absent entirely.
|
||||
@@ -83,8 +87,8 @@ fun olderThan(
|
||||
raw: String?,
|
||||
minutes: Long,
|
||||
): Boolean {
|
||||
val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() }
|
||||
return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE)
|
||||
val at = raw?.let { epochMillis(it) }
|
||||
return at == null || at < System.currentTimeMillis() - minutes * MILLIS_PER_MINUTE
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -113,6 +122,62 @@ fun UpdateCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The nag: an update is waiting, said where someone will actually see it.
|
||||
*
|
||||
* Until this existed the only way to learn about a new build was to open the sync
|
||||
* screen and press Check — so the updates that got installed were the ones somebody
|
||||
* went looking for, and the rest were simply never found.
|
||||
*
|
||||
* Dismissible, but not permanently. "Later" clears it for this sitting; the next time
|
||||
* the app comes forward the background check finds the same build and says so again.
|
||||
* That is the difference between a reminder and a notice you can lose.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateBanner(
|
||||
version: String,
|
||||
ready: Boolean,
|
||||
busy: Boolean,
|
||||
onInstall: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint("blue")
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(BANNER_RADIUS))
|
||||
.background(tint.background(dark))
|
||||
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
|
||||
.padding(start = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
// Two sentences for two states: fetched already, or waiting to be. The
|
||||
// button is the same either way — the difference is how long it takes.
|
||||
text =
|
||||
stringResource(
|
||||
if (ready) R.string.update_banner_ready else R.string.update_banner_available,
|
||||
version,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (busy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(BANNER_SPINNER), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
} else {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.update_later)) }
|
||||
TextButton(onClick = onInstall) { Text(stringResource(R.string.update_install)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val BANNER_RADIUS = 12.dp
|
||||
private val BANNER_SPINNER = 18.dp
|
||||
|
||||
/**
|
||||
* The one line an unlinked device gets.
|
||||
*
|
||||
|
||||
@@ -24,10 +24,23 @@ data class UpdateState(
|
||||
val available: ClientUpdate? = null,
|
||||
/** A check completed and found nothing. Distinct from "not checked yet". */
|
||||
val upToDate: Boolean = false,
|
||||
/** The available build has been fetched and is sitting in the cache. */
|
||||
val ready: Boolean = false,
|
||||
val downloading: Boolean = false,
|
||||
val working: Boolean = false,
|
||||
val error: String? = null,
|
||||
/** The banner has been waved away — until the app next comes forward. */
|
||||
val nagDismissed: Boolean = false,
|
||||
) {
|
||||
val busy: Boolean get() = checking || working
|
||||
val busy: Boolean get() = checking || downloading || working
|
||||
|
||||
/**
|
||||
* Worth interrupting the board for.
|
||||
*
|
||||
* Not gated on [ready]: on a metered connection nothing is downloaded in advance,
|
||||
* and an update nobody is told about is worse than one that costs a tap to fetch.
|
||||
*/
|
||||
val nagging: Boolean get() = available != null && !nagDismissed && !working
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,28 +66,82 @@ class UpdateViewModel(
|
||||
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
|
||||
private set
|
||||
|
||||
/** Ask the linked server what it has. */
|
||||
fun check() {
|
||||
/** When the last check ran, so coming back to the app twice in a minute is one. */
|
||||
private var lastCheckAt = 0L
|
||||
|
||||
/** Ask the linked server what it has. The Check button on the sync screen. */
|
||||
fun check() = runCheck(fetch = false)
|
||||
|
||||
/**
|
||||
* The automatic path: look, fetch, then nag.
|
||||
*
|
||||
* Called when the app comes forward. Until this existed an update was only ever
|
||||
* found by someone opening the sync screen and pressing a button — so the ones
|
||||
* that mattered were the ones nobody went looking for.
|
||||
*
|
||||
* Skipped when a check is already in flight, when a build is already waiting, and
|
||||
* when one ran recently: flicking between two apps is not a request to re-check.
|
||||
*/
|
||||
fun checkInBackground() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (state.busy || state.ready || now - lastCheckAt < CHECK_INTERVAL_MS) return
|
||||
lastCheckAt = now
|
||||
runCheck(fetch = true)
|
||||
}
|
||||
|
||||
private fun runCheck(fetch: Boolean) {
|
||||
viewModelScope.launch {
|
||||
state = state.copy(checking = true, error = null, upToDate = false)
|
||||
state =
|
||||
try {
|
||||
val found = core.clientUpdate(state.installedVersion)
|
||||
state.copy(checking = false, available = found, upToDate = found == null)
|
||||
state.copy(
|
||||
checking = false,
|
||||
available = found,
|
||||
upToDate = found == null,
|
||||
// A build that is still there is worth mentioning again. The
|
||||
// dismissal was for that sitting, not for this version.
|
||||
nagDismissed = if (found == null) state.nagDismissed else false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Broad by intent, as everywhere the core is called: it reports
|
||||
// every failure as one error type carrying a message written to
|
||||
// be read, and a failed check must not take the screen down.
|
||||
state.copy(checking = false, error = e.message ?: FALLBACK)
|
||||
}
|
||||
// Fetched in advance so the nag is a one-tap install rather than the start
|
||||
// of a wait. Not over mobile data: fifty-odd megabytes is a bill nobody
|
||||
// agreed to, and on a metered link the Install button downloads instead.
|
||||
if (fetch && state.available != null && AppUpdate.onUnmeteredNetwork(context)) {
|
||||
download()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch the waiting build into the cache, leaving it for [install]. */
|
||||
private suspend fun download() {
|
||||
state = state.copy(downloading = true, error = null)
|
||||
state =
|
||||
try {
|
||||
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
|
||||
state.copy(downloading = false, ready = true)
|
||||
} catch (e: Exception) {
|
||||
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop nagging until the app next comes forward and finds it again. */
|
||||
fun dismissNag() {
|
||||
state = state.copy(nagDismissed = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the update and hand it to the system installer.
|
||||
* Hand the update to the system installer, downloading first if it is not already
|
||||
* in the cache.
|
||||
*
|
||||
* One action rather than two buttons: nobody wants a downloaded APK sitting
|
||||
* around as an intermediate state they have to think about.
|
||||
* Still one action from the outside. A downloaded APK is not a state anyone wants
|
||||
* to think about, so whether the fetch already happened in the background is this
|
||||
* class's problem rather than the person's.
|
||||
*/
|
||||
fun downloadAndInstall() {
|
||||
viewModelScope.launch {
|
||||
@@ -83,7 +150,7 @@ class UpdateViewModel(
|
||||
val failure =
|
||||
try {
|
||||
val target = AppUpdate.downloadTarget(context)
|
||||
core.downloadClientUpdate(target.absolutePath)
|
||||
if (!state.ready) core.downloadClientUpdate(target.absolutePath)
|
||||
// Off the main thread: this streams ~55 MiB into the session.
|
||||
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
||||
} catch (e: Exception) {
|
||||
@@ -126,3 +193,14 @@ class UpdateViewModel(
|
||||
}
|
||||
|
||||
private const val FALLBACK = "The update couldn't be checked."
|
||||
private const val FALLBACK_DOWNLOAD = "The update couldn't be downloaded."
|
||||
|
||||
/**
|
||||
* How long a background check stays good for.
|
||||
*
|
||||
* Long enough that switching to another app and back is not a re-check; short enough
|
||||
* that a build published this morning is offered today. The same reasoning as sync's
|
||||
* STALE_MINUTES, at a slower cadence — an app update is not urgent, it is just
|
||||
* something that must not get lost.
|
||||
*/
|
||||
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||
|
||||
@@ -10,16 +10,9 @@
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<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 -->
|
||||
<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
|
||||
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
||||
@@ -38,12 +31,17 @@
|
||||
<string name="board_open_note">Open note</string>
|
||||
<string name="editor_back">Back to notes</string>
|
||||
<string name="editor_add_checklist">Add a checklist</string>
|
||||
<string name="editor_body_hint">Note</string>
|
||||
<string name="editor_body_hint">Take a note…</string>
|
||||
<string name="editor_add_item">Add item</string>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove label</string>
|
||||
<string name="editor_reminder">Set a reminder</string>
|
||||
<string name="editor_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_unpin">Unpin</string>
|
||||
<string name="editor_labels">Labels…</string>
|
||||
@@ -122,6 +120,9 @@
|
||||
<string name="update_current">You\'re on the newest build this server has.</string>
|
||||
<string name="update_check">Check for an update</string>
|
||||
<string name="update_install">Update</string>
|
||||
<string name="update_banner_ready">Build %1$s is downloaded and ready.</string>
|
||||
<string name="update_banner_available">Build %1$s is available.</string>
|
||||
<string name="update_later">Later</string>
|
||||
<string name="update_failed_title">The update didn\'t install</string>
|
||||
<string name="update_permission_title">Android needs your permission</string>
|
||||
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
||||
|
||||
+36
-4
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
|
||||
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
||||
|
||||
use models::{
|
||||
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
|
||||
RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery,
|
||||
ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
@@ -499,6 +499,37 @@ 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]`
|
||||
/// block names, so these stay Rust-side.
|
||||
impl ThoughtSync {
|
||||
@@ -682,8 +713,9 @@ mod tests {
|
||||
assert!(ticked.items[1].checked);
|
||||
assert_eq!(
|
||||
ticked.items[1].text, "charger",
|
||||
"ticking a box must not disturb its text — the two setters write \
|
||||
different columns and neither may clear the other"
|
||||
"ticking a box must not disturb its text — both setters rewrite the \
|
||||
same line of the body now, so one clobbering the other is a live risk \
|
||||
rather than a theoretical one"
|
||||
);
|
||||
|
||||
let renamed = app
|
||||
|
||||
@@ -49,6 +49,33 @@ pub struct Note {
|
||||
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.
|
||||
///
|
||||
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
||||
|
||||
+406
-3
@@ -1,9 +1,18 @@
|
||||
//! Deriving `#tags` from a note's body — the local mirror of what the server computes
|
||||
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the
|
||||
//! frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//! Deriving structure from a note's body — the local mirror of what the server
|
||||
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
|
||||
//! with the frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//!
|
||||
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
||||
//! 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.
|
||||
//!
|
||||
@@ -45,6 +54,239 @@ 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -72,4 +314,165 @@ mod tests {
|
||||
fn empty_body() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+189
-1
@@ -6,7 +6,9 @@
|
||||
//!
|
||||
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::local::derive;
|
||||
|
||||
const SCHEMA_V1: &str = r#"
|
||||
CREATE TABLE notes (
|
||||
@@ -54,6 +56,8 @@ CREATE TABLE checklist_items (
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -180,6 +184,71 @@ ALTER TABLE notes 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.
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
@@ -212,5 +281,124 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(SCHEMA_V7)?;
|
||||
conn.execute_batch("PRAGMA user_version = 7;")?;
|
||||
}
|
||||
if version < 8 {
|
||||
migrate_v8(conn)?;
|
||||
conn.execute_batch("PRAGMA user_version = 8;")?;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+127
-68
@@ -9,7 +9,7 @@
|
||||
|
||||
use chrono::{DateTime, Duration, SecondsFormat, Utc};
|
||||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::local::derive;
|
||||
@@ -24,24 +24,25 @@ fn new_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||
/// The note's NAME: the first line of its body that says anything.
|
||||
///
|
||||
/// 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
|
||||
/// side of the wire.
|
||||
///
|
||||
/// Pure, and given the items rather than fetching them: every caller has already
|
||||
/// loaded them, so a query here would be a second trip for something already in hand.
|
||||
fn display_title(body: &str, items: &[ChecklistItem]) -> String {
|
||||
if let Some(line) = body.lines().map(str::trim).find(|l| !l.is_empty()) {
|
||||
return line.to_string();
|
||||
/// It no longer needs the items, because the items ARE lines of the body now (M304).
|
||||
/// What it needs instead is to strip the task marker off: a list-only note is still
|
||||
/// named by its first item, and calling that note "- [ ] milk" would be showing
|
||||
/// someone the storage rather than the note. An empty item is skipped rather than
|
||||
/// naming the note "", which is what a half-typed list would otherwise do.
|
||||
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
|
||||
.iter()
|
||||
.map(|i| i.text.trim())
|
||||
.find(|t| !t.is_empty())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn escape_like(s: &str) -> String {
|
||||
@@ -69,19 +70,24 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<ChecklistItem>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
Ok(ChecklistItem {
|
||||
id: r.get(0)?,
|
||||
text: r.get(1)?,
|
||||
checked: r.get(2)?,
|
||||
position: r.get(3)?,
|
||||
/// The note's checklist, read out of its body. No query, because there is no table.
|
||||
///
|
||||
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a
|
||||
/// 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
|
||||
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
|
||||
/// on it directly.
|
||||
fn items_of(body: &str) -> Vec<ChecklistItem> {
|
||||
derive::extract_items(body)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| ChecklistItem {
|
||||
id: i.to_string(),
|
||||
text: item.text,
|
||||
checked: item.checked,
|
||||
position: i as i64,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
|
||||
@@ -162,11 +168,10 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
},
|
||||
)?;
|
||||
note.labels = load_labels(conn, id)?;
|
||||
note.items = load_items(conn, id)?;
|
||||
note.items = items_of(¬e.body);
|
||||
note.attachments = load_attachments(conn, id)?;
|
||||
note.previews = load_previews(conn, id)?;
|
||||
// After the items, because a body-only-empty note is named by its first one.
|
||||
note.display_title = display_title(¬e.body, ¬e.items);
|
||||
note.display_title = display_title(¬e.body);
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
@@ -358,23 +363,62 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
|
||||
[],
|
||||
|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(
|
||||
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
|
||||
params![id, input.body, input.color, position, ts],
|
||||
params![id, 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],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
sync_tags(conn, &id, &input.body)?;
|
||||
// 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.
|
||||
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES))
|
||||
.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<()> {
|
||||
let body: String =
|
||||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
|
||||
@@ -391,9 +435,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
|
||||
.as_object()
|
||||
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
||||
|
||||
// Snapshot the pre-edit body before changing it (version history).
|
||||
if obj.contains_key("body") {
|
||||
snapshot_revision(conn, id)?;
|
||||
// Snapshot the pre-edit body before changing it (version history) — but only
|
||||
// once per editing session, and only if it actually changed. See should_snapshot.
|
||||
if let Some(body) = obj.get("body").and_then(|v| v.as_str()) {
|
||||
if should_snapshot(conn, id, body)? {
|
||||
snapshot_revision(conn, id)?;
|
||||
}
|
||||
}
|
||||
|
||||
for (k, v) in obj {
|
||||
@@ -508,18 +555,32 @@ pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite
|
||||
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> {
|
||||
let pos: i64 = conn.query_row(
|
||||
"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)
|
||||
let body = note_body(conn, id)?;
|
||||
set_body(conn, id, derive::append_item(&body, text, false))
|
||||
}
|
||||
|
||||
pub fn update_item(
|
||||
@@ -528,29 +589,27 @@ pub fn update_item(
|
||||
item_id: &str,
|
||||
changes: &Value,
|
||||
) -> 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) {
|
||||
conn.execute(
|
||||
"UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3",
|
||||
params![text, item_id, id],
|
||||
)?;
|
||||
body = derive::set_item_text(&body, index, text);
|
||||
}
|
||||
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
|
||||
conn.execute(
|
||||
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
|
||||
params![checked, item_id, id],
|
||||
)?;
|
||||
body = derive::set_item_checked(&body, index, checked);
|
||||
}
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
set_body(conn, id, body)
|
||||
}
|
||||
|
||||
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2",
|
||||
params![item_id, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
let index = match item_index(item_id) {
|
||||
Some(i) => i,
|
||||
None => return load_note(conn, id),
|
||||
};
|
||||
let body = note_body(conn, id)?;
|
||||
set_body(conn, id, derive::remove_item(&body, index))
|
||||
}
|
||||
|
||||
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The sync wire protocol this client speaks.
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 2;
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
/// The oldest server protocol this client can drive — the symmetric half of the
|
||||
/// server's `min_client_protocol_version`.
|
||||
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2;
|
||||
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
|
||||
/// link rather than degrading it.
|
||||
|
||||
+21
-71
@@ -277,34 +277,12 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
// 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
|
||||
// could leave behind a row the server no longer has.
|
||||
replace_items(conn, note)?;
|
||||
replace_attachments(conn, note)?;
|
||||
replace_previews(conn, note)?;
|
||||
replace_labels(conn, note)?;
|
||||
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<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM attachments WHERE note_id = ?1",
|
||||
@@ -393,16 +371,6 @@ fn ensure_label_stub(conn: &Connection, label: &wire::NoteLabel) -> rusqlite::Re
|
||||
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.
|
||||
///
|
||||
/// NOTE ON ORDERING: the full cycle is push-then-pull (docs/sync.md). Running this
|
||||
@@ -508,12 +476,22 @@ mod tests {
|
||||
sync_revision: revision,
|
||||
purged_at: None,
|
||||
labels: vec![],
|
||||
items: vec![],
|
||||
attachments: 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 {
|
||||
wire::ChangesPage {
|
||||
notes,
|
||||
@@ -612,35 +590,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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 mut first = note("n1", 1);
|
||||
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,
|
||||
},
|
||||
];
|
||||
first.attachments = vec![attachment("a1"), attachment("a2")];
|
||||
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 2);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 2);
|
||||
|
||||
// The server dropped an item; the local copy must drop it too.
|
||||
let mut second = note("n1", 2);
|
||||
second.items = vec![wire::Item {
|
||||
id: "i1".into(),
|
||||
text: "one".into(),
|
||||
checked: true,
|
||||
position: 0,
|
||||
}];
|
||||
second.attachments = vec![attachment("a1")];
|
||||
apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 1);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM attachments"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -810,23 +773,10 @@ mod tests {
|
||||
fn a_page_that_fails_leaves_the_cursor_untouched() {
|
||||
// Atomicity is the whole resumability story: a cursor committed ahead of its
|
||||
// data would skip those rows forever. Force a failure with a duplicate
|
||||
// checklist-item id inside one page.
|
||||
// attachment id inside one page.
|
||||
let conn = db();
|
||||
let mut n = note("n1", 3);
|
||||
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,
|
||||
},
|
||||
];
|
||||
n.attachments = vec![attachment("dup"), attachment("dup")];
|
||||
assert!(apply_page(&conn, &page(vec![n], vec![], 3)).is_err());
|
||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
|
||||
|
||||
@@ -79,8 +79,6 @@ pub struct Change {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<i64>,
|
||||
#[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>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
@@ -103,7 +101,6 @@ impl Change {
|
||||
remind_at: None,
|
||||
recurrence: None,
|
||||
position: None,
|
||||
items: None,
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
name: None,
|
||||
@@ -111,12 +108,6 @@ impl Change {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ItemOut {
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
}
|
||||
|
||||
// --- incoming results --------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -201,7 +192,6 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
||||
remind_at: None,
|
||||
recurrence: None,
|
||||
position: None,
|
||||
items: None,
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
})
|
||||
@@ -267,19 +257,6 @@ fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
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
|
||||
// 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.
|
||||
@@ -305,7 +282,6 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
remind_at: row.remind_at,
|
||||
recurrence: row.recurrence,
|
||||
position: Some(row.position),
|
||||
items: Some(items),
|
||||
label_ids: Some(label_ids),
|
||||
created_at: Some(row.created_at),
|
||||
name: None,
|
||||
|
||||
@@ -58,8 +58,6 @@ pub struct Note {
|
||||
#[serde(default)]
|
||||
pub labels: Vec<NoteLabel>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<Item>,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<Attachment>,
|
||||
#[serde(default)]
|
||||
pub previews: Vec<Preview>,
|
||||
@@ -87,17 +85,6 @@ pub struct NoteLabel {
|
||||
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)]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
|
||||
@@ -3,7 +3,12 @@ import { computed } from "vue";
|
||||
import { parseMarkdown } from "../notes/markdown";
|
||||
import MarkdownInline from "./MarkdownInline.vue";
|
||||
|
||||
const props = defineProps<{ text: string }>();
|
||||
const props = defineProps<{ text: string; toggleable?: boolean }>();
|
||||
// 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));
|
||||
</script>
|
||||
|
||||
@@ -19,6 +24,28 @@ const blocks = computed(() => parseMarkdown(props.text));
|
||||
>
|
||||
<MarkdownInline :tokens="b.inline ?? []" />
|
||||
</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">
|
||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||
</ul>
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { Note } from "../stores/notes";
|
||||
import Icon from "./Icon.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import MarkdownText from "./MarkdownText.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import {
|
||||
cardIdAt,
|
||||
draggingId,
|
||||
@@ -94,6 +93,15 @@ const bodyPreview = computed(() => {
|
||||
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);
|
||||
|
||||
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
|
||||
@@ -263,7 +271,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
blank and the link is never unreachable. -->
|
||||
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
||||
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<MarkdownText :text="bodyPreview" />
|
||||
<MarkdownText :text="bodyPreview" toggleable @toggle="toggleTask" />
|
||||
</div>
|
||||
<p
|
||||
v-if="!note.body && !note.items.length && !note.attachments.length"
|
||||
@@ -279,13 +287,10 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
|
||||
</div>
|
||||
|
||||
<NoteChecklist
|
||||
v-if="note.items.length"
|
||||
:class="note.body ? 'mt-2' : ''"
|
||||
:note-id="note.id"
|
||||
:items="note.items"
|
||||
@click="emit('open', note)"
|
||||
/>
|
||||
<!-- No separate checklist block any more. A checklist is lines of the body (M304),
|
||||
so MarkdownText above draws it in place — which is what lets a list sit between
|
||||
two paragraphs instead of always after them. Rendering both would have shown
|
||||
every list twice. -->
|
||||
|
||||
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<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,12 +5,19 @@ import ColorPicker from "./ColorPicker.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelPicker from "./LabelPicker.vue";
|
||||
import LinkPreview from "./LinkPreview.vue";
|
||||
import NoteChecklist from "./NoteChecklist.vue";
|
||||
import { fromLocalInput, toLocalInput } from "../notes/datetime";
|
||||
import { takeMorphOrigin } from "../composables/useEditorMorph";
|
||||
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||
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
|
||||
// "Take a note…" bar just opens this in compose mode). `note` = the note being edited,
|
||||
@@ -25,16 +32,49 @@ const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void
|
||||
const notes = useNotesStore();
|
||||
|
||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||
const body = ref(props.note?.body ?? props.initialBody);
|
||||
// BLOCKS rather than one string, because a checklist item is drawn as a real checkbox
|
||||
// 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 labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
|
||||
// rather than BEING one, so this is a view flag, not a property of the note: it turns
|
||||
// on when the note already carries items, and when someone asks for one.
|
||||
const checklistOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
// One element per block, keyed by the block's id — the only thing about a block that
|
||||
// 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 uploadError = ref("");
|
||||
|
||||
@@ -75,15 +115,6 @@ const liveNote = computed<Note>(() =>
|
||||
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? 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…";
|
||||
|
||||
// Keep local state in sync when the edited note changes (modal reused for another note).
|
||||
@@ -91,7 +122,7 @@ watch(
|
||||
() => props.note,
|
||||
(n) => {
|
||||
noteId.value = n?.id ?? null;
|
||||
body.value = n?.body ?? "";
|
||||
setBody(n?.body ?? "");
|
||||
color.value = (n?.color ?? "default") as NoteColor;
|
||||
labelList.value = n ? [...n.labels] : [];
|
||||
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
||||
@@ -142,10 +173,9 @@ async function flush(): Promise<void> {
|
||||
|
||||
function resetCompose(): void {
|
||||
noteId.value = null;
|
||||
body.value = "";
|
||||
setBody("");
|
||||
color.value = "default";
|
||||
labelList.value = [];
|
||||
checklistOpen.value = false;
|
||||
baseline.value = { body: "", color: "default" };
|
||||
uploadError.value = "";
|
||||
}
|
||||
@@ -157,7 +187,9 @@ async function commitAndContinue(): Promise<void> {
|
||||
await flush();
|
||||
resetCompose();
|
||||
await nextTick();
|
||||
bodyInput.value?.focus();
|
||||
growAll();
|
||||
const first = blocks.value[0];
|
||||
await focusBlock(first ? first.id : null);
|
||||
}
|
||||
// ---- open/close animation (M7) ----
|
||||
//
|
||||
@@ -227,21 +259,68 @@ function onBackdropMousedown(): void {
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
const el = bodyInput.value;
|
||||
el?.focus();
|
||||
// Put the caret after any seeded text (type-to-compose) so typing continues cleanly.
|
||||
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
||||
growAll();
|
||||
// The LAST block, with the caret after its text: opening a note means continuing it,
|
||||
// and type-to-compose seeds text that should be typed straight on from.
|
||||
const last = blocks.value[blocks.value.length - 1];
|
||||
await focusBlock(last ? last.id : null);
|
||||
});
|
||||
|
||||
function onBodyKeydown(e: KeyboardEvent) {
|
||||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
||||
/** Editing one block: replace its text, leave every other block alone. */
|
||||
function setText(index: number, text: string): void {
|
||||
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) {
|
||||
e.preventDefault();
|
||||
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 ----
|
||||
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
||||
async function onReminderChange(e: Event) {
|
||||
@@ -285,14 +364,17 @@ function labelChip(c: string): string {
|
||||
|
||||
// ---- add a checklist ----
|
||||
//
|
||||
// Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
|
||||
// body and gains a place to put items. Persists the draft first for the same reason
|
||||
// attaching a file does — an item needs a note to belong to.
|
||||
async function addChecklist() {
|
||||
if (checklistOpen.value) return;
|
||||
const id = await ensureDraft();
|
||||
if (!id) return;
|
||||
checklistOpen.value = true;
|
||||
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
|
||||
// this one needs NO persisted note to hang anything off — a checklist is part of the
|
||||
// body (M304), so it works on an empty compose box the moment it opens.
|
||||
//
|
||||
// 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 be the one being looked at by
|
||||
// the time this runs.
|
||||
function addChecklist(): void {
|
||||
const next = plusTask(blocks.value);
|
||||
blocks.value = next.blocks;
|
||||
void focusBlock(next.focus);
|
||||
}
|
||||
|
||||
// ---- attachments ----
|
||||
@@ -371,7 +453,7 @@ async function restoreRevisionAt(revId: string) {
|
||||
const id = noteId.value;
|
||||
if (!id) return;
|
||||
const updated = await notes.restoreRevision(id, revId);
|
||||
body.value = updated.body;
|
||||
setBody(updated.body);
|
||||
color.value = updated.color;
|
||||
baseline.value = { body: updated.body, color: updated.color };
|
||||
void loadRevisions(); // the pre-restore state became a new revision
|
||||
@@ -474,22 +556,52 @@ function revPreview(rev: NoteRevision): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
:placeholder="bodyPlaceholder"
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@keydown="onBodyKeydown"
|
||||
/>
|
||||
<!-- Below the body, not instead of it. -->
|
||||
<NoteChecklist
|
||||
v-if="showChecklist"
|
||||
class="py-1"
|
||||
:note-id="liveNote.id"
|
||||
:items="liveNote.items"
|
||||
editable
|
||||
/>
|
||||
<!-- 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
|
||||
v-else
|
||||
:ref="(el) => setBlockEl(block.id, el)"
|
||||
:value="block.text"
|
||||
rows="1"
|
||||
:placeholder="i === 0 ? bodyPlaceholder : ''"
|
||||
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@input="onProseInput(i, $event)"
|
||||
@keydown="onProseKeydown"
|
||||
/>
|
||||
</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">
|
||||
<span
|
||||
@@ -598,7 +710,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
</button>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
|
||||
<button
|
||||
v-if="richEnabled && !liveNote.trashed && !showChecklist"
|
||||
v-if="!liveNote.trashed"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Add a checklist"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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,10 +13,22 @@ export interface InlineToken {
|
||||
|
||||
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
|
||||
export interface Block {
|
||||
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre";
|
||||
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre" | "task";
|
||||
inline?: InlineToken[];
|
||||
items?: InlineToken[][];
|
||||
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;
|
||||
@@ -44,11 +56,45 @@ export function parseInline(text: string): InlineToken[] {
|
||||
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[] {
|
||||
const lines = (text ?? "").split("\n");
|
||||
const blocks: Block[] = [];
|
||||
let paragraph: string[] = [];
|
||||
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 = () => {
|
||||
if (paragraph.length) {
|
||||
@@ -96,6 +142,25 @@ export function parseMarkdown(text: string): Block[] {
|
||||
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.
|
||||
if (/^\s*[-*+]\s+/.test(line)) {
|
||||
flushPara();
|
||||
|
||||
@@ -9,7 +9,6 @@ from . import ( # noqa: F401
|
||||
label,
|
||||
note,
|
||||
note_attachment,
|
||||
note_item,
|
||||
note_link_preview,
|
||||
note_revision,
|
||||
saved_filter,
|
||||
|
||||
@@ -46,7 +46,6 @@ class Note(Base):
|
||||
display_title: 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")
|
||||
# 'text' (freeform body) or 'list' (a checklist of note_items).
|
||||
# Manual drag order (higher = earlier); 0 until the user reorders.
|
||||
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
||||
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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,9 +31,16 @@ from ..labeling import reconcile_manual_labels, resolve_owned_label_ids
|
||||
from ..models.label import Label, NoteLabel
|
||||
from ..models.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
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 ..retention import purge_note
|
||||
from ..settings import get_setting
|
||||
@@ -69,7 +76,7 @@ from .tags import (
|
||||
parse_tags,
|
||||
)
|
||||
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
|
||||
from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes
|
||||
from .serialize import _labels_for_notes, _serialize_note, _serialize_notes
|
||||
|
||||
__all__ = [
|
||||
"bp",
|
||||
@@ -224,7 +231,6 @@ async def export_notes():
|
||||
).all()
|
||||
ids = [n.id for n in notes_list]
|
||||
labels_map = await _labels_for_notes(db, ids)
|
||||
items_map = await _items_for_notes(db, ids)
|
||||
att_rows = (
|
||||
(await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id.in_(ids)))).all() if ids else []
|
||||
)
|
||||
@@ -246,7 +252,6 @@ async def export_notes():
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for n in notes_list:
|
||||
labels = labels_map.get(n.id, [])
|
||||
items = items_map.get(n.id, [])
|
||||
atts = att_by_note.get(n.id, [])
|
||||
short = str(n.id)[:8]
|
||||
payload["notes"].append(
|
||||
@@ -262,13 +267,12 @@ async def export_notes():
|
||||
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||
"labels": [lb["name"] for lb in labels],
|
||||
"items": [{"text": it["text"], "checked": it["checked"]} for it in items],
|
||||
"attachments": [
|
||||
{"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, items))
|
||||
zf.writestr(f"notes/{_slugify(n.display_title)}-{short}.md", _note_markdown(n, labels))
|
||||
for a in atts:
|
||||
src = Config.media_root() / a.path
|
||||
if src.is_file():
|
||||
@@ -384,24 +388,6 @@ async def reorder_notes():
|
||||
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("")
|
||||
@login_required
|
||||
async def create_note():
|
||||
@@ -418,17 +404,20 @@ async def create_note():
|
||||
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(
|
||||
owner_id=g.user_id,
|
||||
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
|
||||
display_title=derive_display_title(body),
|
||||
body=body,
|
||||
color=normalize_color(data.get("color")),
|
||||
position=int(max_pos) + 1,
|
||||
)
|
||||
db.add(note)
|
||||
await db.flush() # assign note.id before writing items/links
|
||||
for pos, text in enumerate(item_texts):
|
||||
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
||||
await db.flush() # assign note.id before writing links
|
||||
# The FOLDED body: an item can carry a #tag too.
|
||||
await _reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
@@ -483,10 +472,12 @@ async def update_note(note_id: str):
|
||||
if "recurrence" in data:
|
||||
note.recurrence = normalize_recurrence(data["recurrence"])
|
||||
if "body" in data:
|
||||
note.display_title = await _name_for(db, note)
|
||||
note.display_title = derive_display_title(note.body)
|
||||
await _reconcile_tags(db, note)
|
||||
# Version history: snapshot the PRE-edit body whenever it changed.
|
||||
if note.body != old_body:
|
||||
# Version history: snapshot the PRE-edit body, once per editing session
|
||||
# rather than once per write — see revisions.should_snapshot. Writing often
|
||||
# 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))
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
@@ -540,7 +531,7 @@ async def restore_revision(note_id: str, rev_id: str):
|
||||
# the revision — with the same body ripple as a normal edit.
|
||||
db.add(NoteRevision(note_id=note.id, body=note.body))
|
||||
note.body = rev.body
|
||||
note.display_title = await _name_for(db, note)
|
||||
note.display_title = derive_display_title(note.body)
|
||||
await _reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
@@ -572,11 +563,35 @@ async def set_note_labels(note_id: str):
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
|
||||
|
||||
async def _get_item(db, note: Note, item_id: str) -> NoteItem | None:
|
||||
iid = parse_uuid(item_id)
|
||||
if iid is None:
|
||||
def _item_index(item_id: str) -> int | None:
|
||||
"""An item's id is its ordinal (see serialize.items_of). Anything else is a stale
|
||||
id from a client that has not reloaded, and the answer to those is 404."""
|
||||
try:
|
||||
index = int(item_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return await db.scalar(select(NoteItem).where(NoteItem.id == iid, NoteItem.note_id == note.id))
|
||||
return index if index >= 0 else None
|
||||
|
||||
|
||||
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")
|
||||
@@ -588,70 +603,49 @@ async def add_item(note_id: str):
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
max_pos = await db.scalar(
|
||||
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
|
||||
response = await _rewrite_body(db, note, append_item(note.body, text))
|
||||
return response, 201
|
||||
|
||||
|
||||
@bp.patch("/<note_id>/items/<item_id>")
|
||||
@login_required
|
||||
async def update_item(note_id: str, item_id: str):
|
||||
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:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
item = await _get_item(db, note, item_id)
|
||||
if item is None:
|
||||
if index >= len(parse_items(note.body)):
|
||||
return not_found()
|
||||
body = note.body
|
||||
if "text" in data and isinstance(data["text"], str):
|
||||
item.text = data["text"]
|
||||
body = set_item_text(body, index, data["text"])
|
||||
if "checked" in data:
|
||||
item.checked = bool(data["checked"])
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
body = set_item_checked(body, index, bool(data["checked"]))
|
||||
return await _rewrite_body(db, note, body)
|
||||
|
||||
|
||||
@bp.delete("/<note_id>/items/<item_id>")
|
||||
@login_required
|
||||
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:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
item = await _get_item(db, note, item_id)
|
||||
if item is None:
|
||||
if index >= len(parse_items(note.body)):
|
||||
return not_found()
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
return await _rewrite_body(db, note, remove_item(note.body, index))
|
||||
|
||||
|
||||
@bp.post("/<note_id>/items/reorder")
|
||||
@login_required
|
||||
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))
|
||||
# The reorder route is gone with M304. Reordering a checklist is moving a line, which
|
||||
# is something a text editor already does and no client ever called this for — the
|
||||
# only reference to it in the tree was a test asserting the route existed.
|
||||
|
||||
|
||||
@bp.post("/<note_id>/attachments")
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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,6 +3,8 @@ board-filter narrowing, the owner-scoped fetch, and filename/slug sanitizers use
|
||||
both the attachment routes and the importer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .checklist import strip_marker
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
@@ -19,29 +21,36 @@ VALID_FILTERS = {"active", "archived", "trash"}
|
||||
DISPLAY_TITLE_CAP = 200
|
||||
|
||||
|
||||
def derive_display_title(body: str | None, first_item: str | None = None) -> str:
|
||||
"""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).
|
||||
def derive_display_title(body: str | None) -> str:
|
||||
"""The note's display NAME: the first line of the body that says anything.
|
||||
|
||||
There is no explicit title to prefer any more (M13 step 3) — a note is a body plus
|
||||
optional items, and its name is simply the first thing written in it. Persisted as
|
||||
notes.display_title so search results and export filenames have something to say.
|
||||
There is no explicit title to prefer any more (M13 step 3) — a note is a body, and
|
||||
its name is simply the first thing written in it. Persisted as notes.display_title
|
||||
so search results and export filenames have something to say.
|
||||
|
||||
The item fallback is what step 2 bought: a note that is only a checklist would
|
||||
otherwise have no name at all, which is exactly the hole that made removing the
|
||||
title unsafe before checklists stopped being their own kind of thing.
|
||||
The old `first_item` fallback is gone with M304: items ARE body lines now, so a
|
||||
list-only note is named by its first item without anyone having to arrange it. What
|
||||
replaced the fallback is stripping the task marker — calling that note "- [ ] milk"
|
||||
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.
|
||||
|
||||
Deterministic — a literal first line, never generated.
|
||||
Mirrors `display_title` in core/src/local/store.rs. Deterministic — a literal first
|
||||
line, never generated.
|
||||
"""
|
||||
for line in (body or "").splitlines():
|
||||
stripped = line.strip()
|
||||
stripped = strip_marker(line.strip()).strip()
|
||||
if stripped:
|
||||
return stripped[:DISPLAY_TITLE_CAP]
|
||||
return (first_item or "").strip()[:DISPLAY_TITLE_CAP]
|
||||
return ""
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from ..config import Config
|
||||
from ..models.label import NoteLabel
|
||||
from ..models.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from .helpers import (
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
_attachment_ext,
|
||||
@@ -28,11 +27,12 @@ from .helpers import (
|
||||
derive_display_title,
|
||||
is_empty_note,
|
||||
)
|
||||
from .checklist import append_item
|
||||
from .tags import _find_or_create_label, _reconcile_tags
|
||||
from .recurrence import normalize_recurrence
|
||||
|
||||
|
||||
def _note_markdown(note: Note, labels: list, items: list) -> str:
|
||||
def _note_markdown(note: Note, labels: list) -> str:
|
||||
"""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."""
|
||||
fm = ["---"]
|
||||
@@ -54,11 +54,9 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
|
||||
# are written, body first, with a blank line between them when there is.
|
||||
if note.body:
|
||||
fm.append(note.body)
|
||||
if items:
|
||||
if note.body:
|
||||
fm.append("")
|
||||
for it in items:
|
||||
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
|
||||
# No separate items block any more. The body already ends with those exact lines
|
||||
# (M304) — this function is where their layout was decided, and appending them a
|
||||
# second time would double every checklist in an export.
|
||||
return "\n".join(fm) + "\n"
|
||||
|
||||
|
||||
@@ -294,9 +292,17 @@ async def _create_imported_note(
|
||||
if is_empty_note(body, item_texts):
|
||||
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(
|
||||
owner_id=owner_id,
|
||||
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
|
||||
display_title=derive_display_title(body),
|
||||
body=body,
|
||||
color=normalize_color(spec.get("color")),
|
||||
pinned=bool(spec.get("pinned")),
|
||||
@@ -316,12 +322,7 @@ async def _create_imported_note(
|
||||
if spec.get("updated_at"):
|
||||
note.updated_at = spec["updated_at"]
|
||||
db.add(note)
|
||||
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))
|
||||
await db.flush() # assign note.id before labels/attachments/links
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -8,8 +8,8 @@ from sqlalchemy import select
|
||||
from ..models.label import Label, NoteLabel
|
||||
from ..models.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
from .checklist import parse_items
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _serialize_item(item: NoteItem) -> dict:
|
||||
return {"id": str(item.id), "text": item.text, "checked": item.checked, "position": item.position}
|
||||
def items_of(body: str | None) -> list[dict]:
|
||||
"""The note's checklist, read out of its body. No query, because there is no table.
|
||||
|
||||
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.
|
||||
|
||||
async def _items_for_notes(db, note_ids: list) -> dict:
|
||||
"""Map note_id -> [checklist items] in one query, ordered by position."""
|
||||
result: dict = {}
|
||||
if not note_ids:
|
||||
return result
|
||||
items = (
|
||||
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
|
||||
The id is the item's ORDINAL, which is what the rewriters in `checklist.py` take,
|
||||
so a client holding one can act on it directly. It also shifts when an item is
|
||||
removed — every mutation returns the reloaded note for exactly that reason.
|
||||
"""
|
||||
return [
|
||||
{"id": str(i), "text": item.text, "checked": item.checked, "position": i}
|
||||
for i, item in enumerate(parse_items(body))
|
||||
]
|
||||
|
||||
|
||||
def _attachment_url(note_id, att_id) -> str:
|
||||
@@ -108,8 +108,7 @@ async def _serialize_note(db, note: Note) -> dict:
|
||||
data = note.serialize()
|
||||
labels = await _labels_for_notes(db, [note.id])
|
||||
data["labels"] = labels.get(note.id, [])
|
||||
items = await _items_for_notes(db, [note.id])
|
||||
data["items"] = items.get(note.id, [])
|
||||
data["items"] = items_of(note.body)
|
||||
attachments = await _attachments_for_notes(db, [note.id])
|
||||
data["attachments"] = attachments.get(note.id, [])
|
||||
previews = await _previews_for_notes(db, [note.id])
|
||||
@@ -120,14 +119,14 @@ async def _serialize_note(db, note: Note) -> dict:
|
||||
async def _serialize_notes(db, notes: list) -> list:
|
||||
ids = [n.id for n in notes]
|
||||
labels_map = await _labels_for_notes(db, ids)
|
||||
items_map = await _items_for_notes(db, ids)
|
||||
|
||||
attach_map = await _attachments_for_notes(db, ids)
|
||||
preview_map = await _previews_for_notes(db, ids)
|
||||
out = []
|
||||
for n in notes:
|
||||
data = n.serialize()
|
||||
data["labels"] = labels_map.get(n.id, [])
|
||||
data["items"] = items_map.get(n.id, [])
|
||||
data["items"] = items_of(n.body)
|
||||
data["attachments"] = attach_map.get(n.id, [])
|
||||
data["previews"] = preview_map.get(n.id, [])
|
||||
out.append(data)
|
||||
|
||||
@@ -32,7 +32,6 @@ from .db import session_scope
|
||||
from .models.label import NoteLabel
|
||||
from .models.note import Note
|
||||
from .models.note_attachment import NoteAttachment
|
||||
from .models.note_item import NoteItem
|
||||
from .models.note_link_preview import NoteLinkPreview
|
||||
from .models.note_revision import NoteRevision
|
||||
from .settings import get_setting
|
||||
@@ -85,7 +84,6 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
|
||||
# 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)
|
||||
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(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
||||
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""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
|
||||
+17
-50
@@ -24,8 +24,8 @@ from .db import session_scope
|
||||
from .labeling import reconcile_manual_labels, resolve_owned_label_ids
|
||||
from .models.label import Label, NoteLabel
|
||||
from .models.note import Note
|
||||
from .models.note_item import NoteItem
|
||||
from .models.note_revision import NoteRevision
|
||||
from .revisions import should_snapshot
|
||||
from .notes import (
|
||||
_reconcile_tags,
|
||||
_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
|
||||
# ran against a half-applied v2.
|
||||
SYNC_PROTOCOL_VERSION = 2
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 2
|
||||
SYNC_PROTOCOL_VERSION = 3
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 3
|
||||
|
||||
# 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
|
||||
@@ -212,49 +212,12 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||
note.position = ch["position"]
|
||||
|
||||
|
||||
def _first_item_text(ch: dict) -> str:
|
||||
"""The first non-blank checklist item in a pushed change, or "".
|
||||
|
||||
Read straight from the payload rather than the database because the note's name is
|
||||
computed BEFORE `_apply_note_items` has written anything — and a note whose body is
|
||||
empty is named by its first item (M13 step 3).
|
||||
"""
|
||||
items = ch.get("items")
|
||||
if not isinstance(items, list):
|
||||
return ""
|
||||
for it in items:
|
||||
if isinstance(it, dict):
|
||||
text = (it.get("text") or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
async def _apply_note_items(db, note: Note, ch: dict) -> None:
|
||||
"""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))
|
||||
# `_first_item_text` and `_apply_note_items` lived here until M304. Both existed for
|
||||
# one reason — a checklist was a table beside the body — and both are gone with it. A
|
||||
# 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
|
||||
# sends an `items` array is a v2 client, and the version floor below turns it away
|
||||
# before any of this runs.
|
||||
|
||||
|
||||
async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
|
||||
@@ -314,14 +277,18 @@ async def _apply_note(db, ch: dict) -> dict:
|
||||
|
||||
old_body = note.body
|
||||
_assign_note_fields(note, ch)
|
||||
note.display_title = derive_display_title(note.body, _first_item_text(ch))
|
||||
note.display_title = derive_display_title(note.body)
|
||||
if edited_at is not None:
|
||||
note.updated_at = edited_at
|
||||
# Non-destructive LWW: snapshot the overwritten server body into history.
|
||||
if not creating and note.body != old_body:
|
||||
# Non-destructive LWW: snapshot the overwritten server body into history —
|
||||
# subject to the same session window as a direct edit (revisions.should_snapshot).
|
||||
# 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))
|
||||
await db.flush() # assign note.id before items/labels/links
|
||||
await _apply_note_items(db, note, ch)
|
||||
await _reconcile_tags(db, note)
|
||||
await _apply_note_manual_labels(db, note, ch)
|
||||
await db.flush()
|
||||
|
||||
+111
-52
@@ -16,6 +16,7 @@ deployment has ever seen.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -25,19 +26,20 @@ from thoughtsync import ratelimit
|
||||
from thoughtsync.app import create_app
|
||||
from thoughtsync.db import dispose_engine, session_scope
|
||||
from thoughtsync.models.note import Note
|
||||
from thoughtsync.models.note_item import NoteItem
|
||||
from thoughtsync.models.user import User
|
||||
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.models.note_link_preview import NoteLinkPreview
|
||||
from thoughtsync.sync import _apply_note_items
|
||||
from thoughtsync.models.note_revision import NoteRevision
|
||||
from thoughtsync.revisions import REVISION_WINDOW_MINUTES, should_snapshot
|
||||
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# 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.
|
||||
_TABLES = "notes, note_items, note_revisions, note_labels, note_link_previews, labels, users"
|
||||
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, users"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -157,65 +159,55 @@ async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
|
||||
assert body_only == 1
|
||||
|
||||
|
||||
async def test_a_note_keeps_both_its_body_and_its_items(db, owner):
|
||||
"""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")
|
||||
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()
|
||||
async def test_a_note_keeps_its_prose_on_both_sides_of_its_list(db, owner):
|
||||
"""The shape M304 made expressible at all.
|
||||
|
||||
items = (
|
||||
await db.scalars(select(NoteItem).where(NoteItem.note_id == note.id).order_by(NoteItem.position))
|
||||
).all()
|
||||
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_sync_no_longer_deletes_items_from_a_note_with_a_body(db, owner):
|
||||
"""The data-loss path step 2 removed, pinned against a real database.
|
||||
|
||||
`_apply_note_items` used to delete every item when the note wasn't `kind = "list"`.
|
||||
Nothing can produce that state any more, but this is the regression that would
|
||||
have silently eaten a checklist, and it deserves a test that would catch its
|
||||
return.
|
||||
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.
|
||||
"""
|
||||
note = Note(owner_id=owner.id, body="packing", display_title="packing")
|
||||
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)
|
||||
await db.flush()
|
||||
db.add(NoteItem(note_id=note.id, text="socks", position=0))
|
||||
await db.commit()
|
||||
|
||||
# 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)]
|
||||
stored = await db.scalar(select(Note.body).where(Note.id == note.id))
|
||||
assert [(i.text, i.checked) for i in parse_items(stored)] == [("milk", False), ("eggs", True)]
|
||||
assert stored.splitlines()[0] == "weekend shop"
|
||||
assert stored.splitlines()[-1] == "back before six"
|
||||
|
||||
|
||||
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="")
|
||||
async def test_ticking_an_item_is_a_body_edit(db, owner):
|
||||
"""What replaced `_apply_note_items`: there is no separate thing left to apply.
|
||||
|
||||
The regression that function guarded against — a sync silently eating a checklist
|
||||
off a note that also had a body — cannot recur, because there is nothing to delete.
|
||||
A pushed body either has the lines or it does not.
|
||||
"""
|
||||
body = "packing\n\n- [ ] socks"
|
||||
note = Note(owner_id=owner.id, body=body, display_title="packing")
|
||||
db.add(note)
|
||||
await db.flush()
|
||||
db.add(NoteItem(note_id=note.id, text="milk", position=0))
|
||||
await db.commit()
|
||||
|
||||
first = await db.scalar(
|
||||
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
|
||||
)
|
||||
note.display_title = derive_display_title(note.body, first)
|
||||
note.body = set_item_checked(note.body, 0, True)
|
||||
await db.commit()
|
||||
|
||||
stored = await db.scalar(select(Note.body).where(Note.id == note.id))
|
||||
assert stored == "packing\n\n- [x] socks"
|
||||
assert parse_items(stored)[0].checked
|
||||
# The prose is untouched — a tick rewrites one line, not the note.
|
||||
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()
|
||||
|
||||
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
|
||||
@@ -414,3 +406,70 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
|
||||
assert row["type"] == "int"
|
||||
assert row["minimum"] is not None and row["maximum"] is not None
|
||||
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
|
||||
|
||||
+176
-12
@@ -5,12 +5,21 @@ import pytest
|
||||
from thoughtsync.app import create_app
|
||||
from thoughtsync.common import coerce_bool, parse_dt
|
||||
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.notes import (
|
||||
_attachment_ext,
|
||||
_header_filename,
|
||||
_keep_spec,
|
||||
_native_spec,
|
||||
_note_markdown,
|
||||
_safe_filename,
|
||||
_slugify,
|
||||
_usec_to_dt,
|
||||
@@ -42,7 +51,7 @@ def test_all_note_routes_registered(app):
|
||||
"reorder_notes", "create_note",
|
||||
"get_note", "update_note", "list_revisions", "restore_revision",
|
||||
"set_note_labels", "add_item", "update_item", "delete_item",
|
||||
"reorder_items", "upload_attachment", "get_attachment",
|
||||
"upload_attachment", "get_attachment",
|
||||
"delete_attachment", "unfurl_link", "delete_preview", "trash_note",
|
||||
"restore_note", "delete_note",
|
||||
)
|
||||
@@ -131,27 +140,32 @@ def test_derive_display_title_is_the_first_body_line():
|
||||
assert derive_display_title("\n \nreal line\nmore") == "real line"
|
||||
|
||||
|
||||
def test_derive_display_title_falls_back_to_the_first_item():
|
||||
# What step 2 bought: a note that is only a checklist still has a name. Without
|
||||
# this it would have none at all, which is why the title could not go first.
|
||||
assert derive_display_title("", "milk") == "milk"
|
||||
assert derive_display_title(" \n ", " eggs ") == "eggs"
|
||||
# The body still wins when it has anything to say.
|
||||
assert derive_display_title("shopping", "milk") == "shopping"
|
||||
def test_derive_display_title_names_a_list_only_note():
|
||||
# The same property the old `first_item` fallback protected — a note that is only
|
||||
# a checklist still has a name — reached a different way. Items ARE body lines now
|
||||
# (M304), so the first one is simply the first line, with its marker stripped:
|
||||
# calling the note "- [ ] milk" would show someone the storage instead of the note.
|
||||
assert derive_display_title("- [ ] milk\n- [ ] eggs") == "milk"
|
||||
assert derive_display_title(" * [x] eggs ") == "eggs"
|
||||
# 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():
|
||||
assert derive_display_title(None) == ""
|
||||
assert derive_display_title("") == ""
|
||||
assert derive_display_title(" \n ", None) == ""
|
||||
assert derive_display_title(" \n ", " ") == ""
|
||||
assert derive_display_title(" \n ") == ""
|
||||
# A list of nothing but empty items is still a note with no name.
|
||||
assert derive_display_title("- [ ]\n- [ ]") == ""
|
||||
|
||||
|
||||
def test_derive_display_title_caps_length():
|
||||
long = "x" * 300
|
||||
assert derive_display_title(long) == "x" * 200
|
||||
# the item fallback is capped on the same rule
|
||||
assert derive_display_title("", long) == "x" * 200
|
||||
# A first line that happens to be an item is capped on the same rule.
|
||||
assert derive_display_title(f"- [ ] {long}") == "x" * 200
|
||||
|
||||
|
||||
def test_parse_tags():
|
||||
@@ -406,3 +420,153 @@ 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(None) == []
|
||||
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