Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23fd2da91e | ||
|
|
3d490bb6f3 | ||
|
|
86f1e4a08f | ||
|
|
c255b170d4 | ||
|
|
1d3cc7bcd4 | ||
|
|
ae2053d2ed | ||
|
|
47f108c9c8 | ||
|
|
fe18aaa956 | ||
|
|
20e9d535de | ||
|
|
988e1d3f00 | ||
|
|
6fbee27f9c | ||
|
|
cddaf35280 | ||
|
|
6f173b166b | ||
|
|
f92a3d0a99 | ||
|
|
1e2b42af25 | ||
|
|
a48b034a94 | ||
|
|
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,8 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
@@ -62,6 +64,30 @@ object AppUpdate {
|
||||
.setData(Uri.fromParts("package", context.packageName, null))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
/**
|
||||
* Whether this is wifi somebody is not paying by the megabyte for.
|
||||
*
|
||||
* The app fetches its own update in the background, and fifty-odd megabytes over
|
||||
* mobile data is a bill nobody agreed to. Anywhere else it simply waits — the
|
||||
* update is found, nothing is downloaded, and nothing is said until it can be.
|
||||
*
|
||||
* BOTH conditions, deliberately. Wifi alone would still download over a tethered
|
||||
* hotspot, which is mobile data wearing a different hat and the exact bill this
|
||||
* avoids. Unmetered alone would download over an unmetered cellular plan, which
|
||||
* is not what "on wifi" means to the person who asked for it.
|
||||
*
|
||||
* Every uncertain answer is `false`: the cautious one costs nothing.
|
||||
*/
|
||||
fun onWifi(context: Context): Boolean {
|
||||
val caps =
|
||||
context
|
||||
.getSystemService(ConnectivityManager::class.java)
|
||||
?.let { manager -> manager.activeNetwork?.let(manager::getNetworkCapabilities) }
|
||||
return caps != null &&
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
/** Where a download goes: app-private, so no storage permission is involved. */
|
||||
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,23 @@ 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,
|
||||
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 +286,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,17 @@ 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,
|
||||
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 +153,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 +199,25 @@ 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, found but
|
||||
* not yet downloaded, dismissed for this sitting — so the board never has to know
|
||||
* which.
|
||||
*/
|
||||
data class BoardUpdate(
|
||||
val version: String,
|
||||
/** An install is in flight — the banner stays and reports it. */
|
||||
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 +360,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 +374,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
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
// The colour a note has when nothing chose one for it.
|
||||
//
|
||||
// A board of `default` notes is a wall of white rectangles and the eye gets no help
|
||||
// telling one from the next. Every note now carries some tint; this is where an
|
||||
// untagged one gets it.
|
||||
//
|
||||
// "RANDOM" MEANS DERIVED. The operator asked for "random subdued colors", but a tint
|
||||
// rolled at render time would differ between the phone and the browser and change on
|
||||
// every reload. Hashing the note's id is deterministic, identical on every surface,
|
||||
// costs no column and no migration, and a note keeps its colour for life — which is
|
||||
// what "random" actually meant here.
|
||||
//
|
||||
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
|
||||
// over the same key order, and the two must agree exactly or a note is one colour on
|
||||
// the phone and another in the browser. Same discipline as the checklist grammar's
|
||||
// three implementations, and the same reason: a value that disagrees across surfaces
|
||||
// is a bug you cannot unsee and cannot explain.
|
||||
//
|
||||
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
|
||||
// pinned by a host-JVM test, and staying free of `androidx.compose` is what keeps
|
||||
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. The
|
||||
// web side has no test runner at all, so this test is the only mechanical guard the
|
||||
// mirror gets — see the fixture comment in colors.ts.
|
||||
|
||||
/**
|
||||
* The tints a derived colour can land on: `NOTE_TINTS`' keys minus `default`, which
|
||||
* is the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a
|
||||
* deliberate card against the board's `bg-neutral-50`, not as an absence.
|
||||
*
|
||||
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering
|
||||
* this list silently recolours every untagged note on one surface only.
|
||||
*/
|
||||
val DERIVED_TINT_KEYS: List<String> =
|
||||
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
|
||||
|
||||
private const val FNV_OFFSET_BASIS = -0x7ee3623b // 0x811c9dc5 as a signed Int
|
||||
private const val FNV_PRIME = 0x01000193
|
||||
private const val BYTE_MASK = 0xFF
|
||||
private const val UNSIGNED_MASK = 0xFFFFFFFFL
|
||||
|
||||
/**
|
||||
* FNV-1a over the id's bytes, 32-bit.
|
||||
*
|
||||
* Chosen because both languages compute it identically in ten lines with no library.
|
||||
* Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has no equivalent,
|
||||
* and reimplementing Java's from memory in TypeScript is exactly how a mirror drifts.
|
||||
*
|
||||
* `and BYTE_MASK` is a no-op for the ASCII of a UUID, and is kept because it states
|
||||
* the intent — this hashes BYTES, so the TypeScript side reading `charCodeAt(i) &
|
||||
* 0xff` is the same function rather than a coincidence.
|
||||
*
|
||||
* Overflow is the point: Kotlin's `Int` wraps on multiply, which is what the web's
|
||||
* `Math.imul` exists to reproduce.
|
||||
*/
|
||||
fun tintHash(id: String): Int {
|
||||
var hash = FNV_OFFSET_BASIS
|
||||
for (ch in id) {
|
||||
hash = hash xor (ch.code and BYTE_MASK)
|
||||
hash *= FNV_PRIME
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
|
||||
fun derivedTint(id: String): String {
|
||||
// Through Long to read the hash as unsigned. A signed remainder would be negative
|
||||
// for half of all ids and index out of the list.
|
||||
val index = (tintHash(id).toLong() and UNSIGNED_MASK) % DERIVED_TINT_KEYS.size
|
||||
return DERIVED_TINT_KEYS[index.toInt()]
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour key for a LABEL — its chip, and (step 3) every note carrying it.
|
||||
*
|
||||
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
|
||||
* currently `default`: the server mints one as `Label(owner_id=…, name=name)` with no
|
||||
* colour, so tag-driven note colour against that would leave the board exactly as
|
||||
* grey as it was.
|
||||
*
|
||||
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
|
||||
* a hashed colour written at each of the four places a label can be born — and named
|
||||
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
|
||||
* since most tags are born from typing `#grocery`, not from a management screen.
|
||||
* Deriving has no mint points to miss and no backfill for the tags already out there.
|
||||
* The cost is that renaming a tag recolours it, which is fair: the name IS the tag.
|
||||
*
|
||||
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
|
||||
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
|
||||
* `toLowerCase()` are both locale-independent, so the mirror holds.
|
||||
*/
|
||||
fun resolvedLabelColor(
|
||||
name: String,
|
||||
color: String,
|
||||
known: Set<String>,
|
||||
): String =
|
||||
when {
|
||||
color.isNotEmpty() && color != "default" && color in known -> color
|
||||
name.isEmpty() -> "default"
|
||||
else -> derivedTint(name.lowercase())
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour key to actually paint a note with.
|
||||
*
|
||||
* An explicitly-picked colour still wins — the picker is on its way out (milestone
|
||||
* 309 step 5) but it has not gone yet, and a note the operator coloured by hand
|
||||
* changing under them would read as data loss.
|
||||
*
|
||||
* `known` is passed in rather than read from `NOTE_TINTS` so this file stays free of
|
||||
* Compose and therefore testable; `noteTintFor` supplies the real set.
|
||||
*/
|
||||
fun resolvedNoteColor(
|
||||
id: String,
|
||||
color: String,
|
||||
labelColor: String,
|
||||
known: Set<String>,
|
||||
): String =
|
||||
when {
|
||||
color.isNotEmpty() && color != "default" && color in known -> color
|
||||
// The note's FIRST tag. Passed in already resolved, so this does not need to
|
||||
// know that a colourless tag derives one from its name.
|
||||
labelColor.isNotEmpty() -> labelColor
|
||||
// A draft carries DRAFT_ID (""), so there is no identity to derive from yet.
|
||||
// Staying white until the note exists costs one colour change at save time;
|
||||
// hashing the empty string instead would give EVERY draft the same tint and
|
||||
// then change it anyway, which is two surprises where one will do.
|
||||
id.isEmpty() -> "default"
|
||||
else -> derivedTint(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a note's colour was CHOSEN rather than derived — which is what decides
|
||||
* between the two weights the card is drawn at.
|
||||
*
|
||||
* A tag (or, until step 5, the picker) means somebody said what this note is. A
|
||||
* derived tint only means the board should not be a wall of white. Drawing both at
|
||||
* the same weight is what prompted the operator's "the tints look the same as the
|
||||
* chosen colors" — the tint was never meant to shout as loudly as a decision.
|
||||
*/
|
||||
fun noteColorIsChosen(
|
||||
color: String,
|
||||
labelColor: String,
|
||||
known: Set<String>,
|
||||
): Boolean = labelColor.isNotEmpty() || (color.isNotEmpty() && color != "default" && color in known)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The fill for an UNTAGGED note, which is a different job from the palette above.
|
||||
//
|
||||
// The palette has nine keys and they MEAN something: a tag's colour. An untagged
|
||||
// note's fill means nothing at all — it exists so a board is not a monolithic wall.
|
||||
// Tying the second job to the first was the mistake. Nine keys is far too few for a
|
||||
// board of any size, and once the nine were subdued enough not to shout they became
|
||||
// indistinguishable from each other: measured, the nine dark fills were separated by
|
||||
// at most a 1.03 contrast ratio, which is to say not at all. Nine tints that look
|
||||
// like three is exactly the wall the tint was added to break up.
|
||||
//
|
||||
// So this hashes to a colour directly rather than to a key. 338 distinct fills in
|
||||
// dark, 193 in light, against nine.
|
||||
//
|
||||
// TWO AXES, AND THE SECOND ONE IS THE FIX. The old ramp varied hue while pinning
|
||||
// every fill to the same lightness — deliberately, so each would read as a card
|
||||
// against the board. But the eye separates by lightness first, so nine hues at one
|
||||
// lightness read as one card repeated. Varying lightness too is what makes the
|
||||
// difference; the hue alone never could at this darkness.
|
||||
//
|
||||
// It is only SAFE to vary lightness because the card now has a grey edge of its own
|
||||
// (see NoteCard.CARD_EDGE_DARK). While the fill was the only boundary the card had,
|
||||
// it could not afford to drift toward the board. The edge bought that freedom.
|
||||
|
||||
/** Lightness steps a derived fill can land on. Six rather than three because the
|
||||
* levels are what carry the variety, and rather than twelve because past a point
|
||||
* they stop being distinguishable and only cost contrast headroom. */
|
||||
private const val TINT_LEVELS = 6
|
||||
|
||||
/**
|
||||
* Saturation is FIXED, and that is what keeps this subtle no matter which hue it
|
||||
* lands on. Variety comes from hue and lightness; loudness would come from
|
||||
* saturation, so saturation is the one dial the hash never touches.
|
||||
*/
|
||||
private const val DARK_SATURATION = 0.25
|
||||
private const val LIGHT_SATURATION = 0.60
|
||||
|
||||
/**
|
||||
* HSL LIGHTNESS IS NOT LUMINANCE, and the floor here is set by the difference.
|
||||
*
|
||||
* The obvious floor is `neutral-900`'s own lightness, 0.090 — "start at the plain card
|
||||
* surface and climb, so no note ever recedes into the board". That was the first
|
||||
* attempt and it was wrong, because at a FIXED HSL lightness the eye sees wildly
|
||||
* different brightnesses depending on hue: green carries 71% of the luminance formula
|
||||
* and blue only 7%, so at L=0.090 a yellow measures 0.0118 and a blue 0.0061 — the
|
||||
* blue landing 1.41x DARKER than the card it was supposed to match. A sixth of the
|
||||
* board would have been holes rather than variety.
|
||||
*
|
||||
* 0.113 is the lowest floor at which EVERY hue clears the card surface, solved for
|
||||
* rather than guessed. The range then measures 1.11–1.71 against the board where the
|
||||
* old single level managed 1.14, so the floor is unchanged and the ceiling is much
|
||||
* higher. `no generated fill sinks below the card surface` in DerivedTintTest is what
|
||||
* caught the original mistake and what holds this.
|
||||
*/
|
||||
private val DARK_LIGHTNESS = doubleArrayOf(0.113, 0.127, 0.141, 0.155, 0.169, 0.183)
|
||||
|
||||
/**
|
||||
* Light runs the other way, from white down toward the `neutral-50` board and just
|
||||
* past it. A card slightly darker than the board still reads as a card because the
|
||||
* edge says so — the same freedom the edge bought in dark, spent in the other
|
||||
* direction.
|
||||
*/
|
||||
private val LIGHT_LIGHTNESS = doubleArrayOf(1.000, 0.990, 0.980, 0.970, 0.960, 0.950)
|
||||
|
||||
private const val HUE_DEGREES = 360L
|
||||
private const val LEVEL_BIT_SHIFT = 16
|
||||
private const val HUE_SECTOR_DEGREES = 60.0
|
||||
private const val TWO = 2.0
|
||||
private const val CHANNEL_MAX = 255.0
|
||||
private const val ROUND_HALF = 0.5
|
||||
private const val CHANNEL_CEILING = 255
|
||||
private const val ALPHA_OPAQUE = 0xFF
|
||||
private const val ALPHA_BIT_SHIFT = 24
|
||||
private const val RED_BIT_SHIFT = 16
|
||||
private const val GREEN_BIT_SHIFT = 8
|
||||
|
||||
/**
|
||||
* The opaque ARGB fill an untagged note wears, stable for the life of the note.
|
||||
*
|
||||
* Returns an Int rather than a Compose `Color` on purpose: this file stays free of
|
||||
* `androidx.compose` so `DerivedTintTest` can run on the host JVM, and that test is
|
||||
* the only mechanical guard the mirror with colors.ts has.
|
||||
*
|
||||
* Hue and level are read from DIFFERENT parts of the hash so a note's shade is not a
|
||||
* function of its hue — two notes of nearly the same hue should still be able to
|
||||
* differ in weight, which is half of where the variety comes from.
|
||||
*/
|
||||
fun derivedFillArgb(
|
||||
id: String,
|
||||
dark: Boolean,
|
||||
): Int {
|
||||
val hash = tintHash(id).toLong() and UNSIGNED_MASK
|
||||
val level = ((hash shr LEVEL_BIT_SHIFT) % TINT_LEVELS).toInt()
|
||||
return hslToArgb(
|
||||
hue = (hash % HUE_DEGREES).toDouble(),
|
||||
saturation = if (dark) DARK_SATURATION else LIGHT_SATURATION,
|
||||
lightness = if (dark) DARK_LIGHTNESS[level] else LIGHT_LIGHTNESS[level],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Textbook HSL to RGB, written out rather than pulled from a library because the
|
||||
* TypeScript side has to compute the same bytes and there is no library both can
|
||||
* share.
|
||||
*
|
||||
* DOUBLE, NOT FLOAT, and that is not a style choice. JavaScript has one number type
|
||||
* and it is IEEE-754 binary64; a Kotlin `Float` is binary32, so the two would round
|
||||
* differently near a channel boundary and a note would be one byte off between the
|
||||
* phone and the browser. Nobody would ever see that as a bug — they would see two
|
||||
* colours that are "sort of the same" and never work out why. Doubles on both sides
|
||||
* make it the same arithmetic rather than nearly the same.
|
||||
*
|
||||
* Rounding is `floor(v + 0.5)` on both sides, NOT the language's `round`: Kotlin
|
||||
* rounds half away from zero and JavaScript rounds half up, which agree for the
|
||||
* non-negative values here, but stating the rule leaves nothing to have to check.
|
||||
*/
|
||||
private fun hslToArgb(
|
||||
hue: Double,
|
||||
saturation: Double,
|
||||
lightness: Double,
|
||||
): Int {
|
||||
val chroma = (1.0 - abs(TWO * lightness - 1.0)) * saturation
|
||||
val sector = hue / HUE_SECTOR_DEGREES
|
||||
val second = chroma * (1.0 - abs(sector % TWO - 1.0))
|
||||
val match = lightness - chroma / TWO
|
||||
// The six hue sectors, as a table rather than a `when` — which is also the form
|
||||
// colors.ts uses, so the two read as the same function rather than as two people's
|
||||
// idea of it. `sector` is in [0, 6) because the hue it came from is in [0, 360).
|
||||
val ramps =
|
||||
listOf(
|
||||
Triple(chroma, second, 0.0),
|
||||
Triple(second, chroma, 0.0),
|
||||
Triple(0.0, chroma, second),
|
||||
Triple(0.0, second, chroma),
|
||||
Triple(second, 0.0, chroma),
|
||||
Triple(chroma, 0.0, second),
|
||||
)
|
||||
val (red, green, blue) = ramps[sector.toInt()]
|
||||
return (ALPHA_OPAQUE shl ALPHA_BIT_SHIFT) or
|
||||
(channelByte(red + match) shl RED_BIT_SHIFT) or
|
||||
(channelByte(green + match) shl GREEN_BIT_SHIFT) or
|
||||
channelByte(blue + match)
|
||||
}
|
||||
|
||||
private fun channelByte(value: Double): Int = (value * CHANNEL_MAX + ROUND_HALF).toInt().coerceIn(0, CHANNEL_CEILING)
|
||||
@@ -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,16 +75,31 @@ 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)) {
|
||||
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.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.editor_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
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.
|
||||
@@ -83,21 +119,17 @@ fun EditorBottomBar(
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
)
|
||||
}
|
||||
// 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) }) {
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f))
|
||||
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
@@ -105,6 +137,127 @@ fun EditorBottomBar(
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +330,7 @@ fun EditorLabelRow(
|
||||
val dark = isSystemInDarkTheme()
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
note.labels.forEach { label ->
|
||||
val tint = noteTint(label.color)
|
||||
val tint = labelTintFor(label.name, label.color)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
@@ -190,6 +343,7 @@ fun EditorLabelRow(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
if (label.viaTag) {
|
||||
|
||||
@@ -15,59 +15,73 @@ 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.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// Depth, not the boundary — the edge below is that. 1dp: enough to
|
||||
// separate a white card from a #fafafa board, and the web's own
|
||||
// `shadow-sm` is the value it is matching.
|
||||
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
|
||||
// Clipped BEFORE clickable, so the ripple is bounded by the card's
|
||||
// rounded corners instead of a rectangle overhanging them.
|
||||
.clip(RoundedCornerShape(CARD_RADIUS))
|
||||
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
|
||||
.background(tint.background(dark))
|
||||
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
||||
.background(noteCardColor(note, dark))
|
||||
// ONE grey edge on every card, regardless of its colour — the tint is
|
||||
// deliberately not consulted here. See CARD_EDGE_DARK.
|
||||
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// Body then checklist, in order — a note can carry both (M13 step 2), and
|
||||
// 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)
|
||||
// TAGS FIRST. They used to sit under everything else, which on a tall note put
|
||||
// the one thing that says what a note IS below the fold of a glance. A board is
|
||||
// scanned, not read, and the answer to "which of these is about the thing I am
|
||||
// looking for" should be the first thing the eye lands on rather than the last.
|
||||
//
|
||||
// Above the body rather than beside it, because the body's first line is the
|
||||
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
|
||||
// with the thing that identifies the note. A row of its own costs one line and
|
||||
// only on notes that have tags at all.
|
||||
if (note.labels.isNotEmpty()) {
|
||||
LabelChips(labels = note.labels)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
// Body then checklist, in order — a note can carry both (M13 step 2). 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()) {
|
||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -76,11 +90,6 @@ fun NoteCard(
|
||||
)
|
||||
}
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LabelChips(labels = note.labels)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||
@@ -88,18 +97,83 @@ 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 ->
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (lines.size > MAX_PREVIEW_LINES) {
|
||||
Text(
|
||||
text = "…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One checklist row on a card, with a box you can actually tick.
|
||||
*
|
||||
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
|
||||
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
|
||||
* space a list out like a form. The tap target is the glyph's own padding, which is
|
||||
* why it carries `clickable` rather than the row — clicking the TEXT should open the
|
||||
* note, the way clicking anywhere else on the card does.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChecklistRow(
|
||||
item: BodyItem,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// 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),
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = item.text,
|
||||
@@ -111,21 +185,10 @@ private fun Checklist(items: List<ChecklistItem>) {
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = 2,
|
||||
maxLines = MAX_WRAPPED_LINES,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val hidden = items.size - MAX_CHECKLIST_ROWS
|
||||
if (hidden > 0) {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -135,7 +198,7 @@ private fun LabelChips(labels: List<NoteLabel>) {
|
||||
// not grow taller than its content. The editor shows the full set.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
||||
val tint = noteTint(label.color)
|
||||
val tint = labelTintFor(label.name, label.color)
|
||||
Text(
|
||||
text = label.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -146,6 +209,7 @@ private fun LabelChips(labels: List<NoteLabel>) {
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), RoundedCornerShape(CHIP_RADIUS))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -182,7 +246,36 @@ 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 CARD_ELEVATION = 1.dp
|
||||
|
||||
// THE CARD'S EDGE — one grey, every card, both weights, all ten colour keys. It is a
|
||||
// constant here rather than a column in NoteTint precisely so the palette CANNOT vary
|
||||
// it; uniformity is the feature.
|
||||
//
|
||||
// The version of this that came from the palette was a `{hue}-900` border, and it
|
||||
// failed twice over: the line measured 1.56-2.09 against its own fill while the fill
|
||||
// managed only 1.03-1.05 against the board, so it was the loudest thing on the card —
|
||||
// and it carried the same information the fill did. A field of cards read as a grid of
|
||||
// outlines however different the colours inside were. A neutral line carries no
|
||||
// information at all, which is exactly what lets it be structure instead of content.
|
||||
//
|
||||
// The two values are MATCHED rather than chosen by eye: each measures ~1.6-1.7 against
|
||||
// the card it edges (light 1.57-1.98 across all twenty fills, dark 1.58-1.73), so the
|
||||
// edge reads with the same authority in either theme. Dark is `neutral-700`, which is
|
||||
// what the `default` card's border always was — one entry's value promoted to the rule
|
||||
// for all of them. Light sits between `neutral-300` and `neutral-400`, neither of which
|
||||
// lands in range: 300 fades to 1.18 on a gray-tagged card, 400 jumps to 2.52 and reads
|
||||
// as a wireframe.
|
||||
//
|
||||
// NOT a translucent black/white edge, which is the tidier way to write this and was
|
||||
// measured and rejected: a border composites over the card's own fill, so `White` at
|
||||
// 20% comes out #56396D on a purple card and #A3C9C1 on a teal one. Hue-coded edges are
|
||||
// the thing being removed.
|
||||
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
|
||||
private val CARD_EDGE_DARK = Color(0xFF404040)
|
||||
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,36 +32,63 @@ 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?,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
val tint = noteTintFor(note)
|
||||
|
||||
// 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,71 +142,101 @@ fun NoteEditorScreen(
|
||||
// is exactly the failure that makes someone stop trusting a notes app.
|
||||
FlushOnStop(flush)
|
||||
|
||||
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
|
||||
// status bar AND consumes that inset, so the bar inside adds no second gap of
|
||||
// its own — the strip above the rounded corner is what makes this read as a card
|
||||
// over the board rather than a screen that replaced it.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
|
||||
color = noteCardColor(note, 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),
|
||||
containerColor = noteCardColor(note, dark),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = leave) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.editor_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
EditorBottomBar(
|
||||
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)
|
||||
.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.
|
||||
// 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) })
|
||||
ErrorBanner(
|
||||
message = message,
|
||||
onDismiss = { onAction(EditorAction.DismissError) },
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
// 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 },
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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.
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
@@ -171,6 +252,8 @@ fun NoteEditorScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorOverlays(
|
||||
note = note,
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.fabledsword.thoughtsync.ui
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
|
||||
/**
|
||||
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
|
||||
@@ -13,9 +14,12 @@ import androidx.compose.ui.graphics.Color
|
||||
* is amber on the desktop is the same amber on the phone rather than a near-miss.
|
||||
* Generated from tailwindcss 3.4's palette rather than transcribed by eye.
|
||||
*
|
||||
* Dark tints keep the web's ALPHA (`dark:bg-red-950/40`) instead of a
|
||||
* precomputed blend — Compose composites a translucent colour over what's beneath
|
||||
* exactly as CSS does, so the card sits on the background the same way in both.
|
||||
* Dark tints keep the web's ALPHA (`dark:bg-red-950/70`) instead of a precomputed
|
||||
* blend — Compose composites a translucent colour over what's beneath exactly as CSS
|
||||
* does, so the card sits on the background the same way in both.
|
||||
*
|
||||
* This table is the palette of MEANINGFUL colours: a tag's. The fill an untagged note
|
||||
* wears is generated rather than looked up, and lives in DerivedTint.kt.
|
||||
*
|
||||
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
|
||||
* acid against the neutral surfaces.
|
||||
@@ -30,16 +34,86 @@ data class NoteTint(
|
||||
val lightChipForeground: Color,
|
||||
val darkChipBackground: Color,
|
||||
val darkChipForeground: Color,
|
||||
/**
|
||||
* False only for `default`, which is the ABSENCE of a colour rather than one of
|
||||
* them. A tint can be drawn at the chosen weight (see [chosenBackground]);
|
||||
* `default` cannot, because there is no such thing as an emphatic lack of colour —
|
||||
* and re-alphaing its opaque neutral fill would make a draft card translucent.
|
||||
*/
|
||||
val tintable: Boolean = true,
|
||||
) {
|
||||
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
|
||||
|
||||
/**
|
||||
* The fill for a note whose colour was CHOSEN — by a tag, or (until step 5) by
|
||||
* the picker. A note with no tag does not come through here at all; see
|
||||
* [noteCardColor].
|
||||
*
|
||||
* That split IS the design. The palette's nine keys mean something: which tag.
|
||||
* An untagged note's fill means nothing, and tying the two together is what left
|
||||
* the board monolithic — nine keys is far too few for a board of any size, and
|
||||
* subdued enough not to shout they became indistinguishable from one another
|
||||
* (measured: the nine dark fills sat within a 1.03 contrast of each other).
|
||||
* Meaning gets a palette; texture gets a generator.
|
||||
*
|
||||
* THE CARD'S EDGE IS NOT A TINT and never comes from here. It used to be a 1px
|
||||
* `{hue}-900` border measuring 1.56–2.09 against its own fill while the fill
|
||||
* managed 1.03–1.05 against the board — the loudest thing on every card saying
|
||||
* exactly what the fill already said, so a field of them read as a grid of
|
||||
* outlines. The edge is now one grey for all ten keys, held as a constant in
|
||||
* `NoteCard.kt` where the palette cannot reach it. [border] is untouched and
|
||||
* still serves panels, banners, the update card and the pickers.
|
||||
*
|
||||
* Light is one Tailwind step deeper (`-100`, which is exactly
|
||||
* [lightChipBackground] — already in this table, so no new hex is transcribed);
|
||||
* dark is the `-950` at [STRONG_DARK_ALPHA].
|
||||
*/
|
||||
fun chosenBackground(dark: Boolean): Color =
|
||||
when {
|
||||
!tintable -> background(dark)
|
||||
dark -> darkBackground.copy(alpha = STRONG_DARK_ALPHA)
|
||||
else -> lightChipBackground
|
||||
}
|
||||
|
||||
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
|
||||
|
||||
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
|
||||
|
||||
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
||||
|
||||
/**
|
||||
* A hairline edge for a chip, in its own text colour at low alpha.
|
||||
*
|
||||
* Needed because a chip's fill can no longer be trusted to differ from what is
|
||||
* behind it. A tagged note takes its FIRST tag's colour, and that card is drawn
|
||||
* at exactly [lightChipBackground] — the same value the chip uses — so in light
|
||||
* mode the pill was measured at a contrast ratio of 1.00 against the card it had
|
||||
* itself coloured. Perfectly invisible: the tag name read as loose text.
|
||||
*
|
||||
* An edge rather than shifting the fill, because the fill can collide with any
|
||||
* card colour and chasing that needs the chip to know what it is sitting on. A
|
||||
* border in the chip's own foreground always reads, against any background, and
|
||||
* needs no plumbing.
|
||||
*/
|
||||
fun chipBorder(dark: Boolean): Color = chipForeground(dark).copy(alpha = CHIP_EDGE_ALPHA)
|
||||
}
|
||||
|
||||
// How strongly a chip's edge is drawn, as a fraction of its own text colour.
|
||||
//
|
||||
// 0.60 measured, not guessed: against the worst case — a chip on a card of its own
|
||||
// colour — this is a 2.32:1 boundary, where 0.30 gave only 1.49:1 and was effectively
|
||||
// no edge at all. It does NOT reach the 3:1 of WCAG 1.4.11, which needs 0.80 and
|
||||
// draws a hard outline rather than a hairline. 1.4.11 governs boundaries that carry
|
||||
// REQUIRED information, and a chip's information is its text — which passes AA at
|
||||
// 8:1 or better on every card in the palette. The edge restores the pill's shape;
|
||||
// it does not carry the meaning. Raise to 0.80 if that judgment is ever overruled.
|
||||
private const val CHIP_EDGE_ALPHA = 0.60f
|
||||
|
||||
// The chosen weight in dark, as a fraction. Mirrors `dark:bg-{hue}-950/70` in
|
||||
// colors.ts. There is no counterpart any more: an untagged note's fill is generated
|
||||
// rather than drawn from this table at a second weight. See DerivedTint.kt.
|
||||
private const val STRONG_DARK_ALPHA = 0.70f
|
||||
|
||||
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
|
||||
val NOTE_TINTS: Map<String, NoteTint> =
|
||||
mapOf(
|
||||
@@ -54,6 +128,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
lightChipForeground = Color(0xFF525252),
|
||||
darkChipBackground = Color(0x1AFFFFFF),
|
||||
darkChipForeground = Color(0xFFD4D4D4),
|
||||
tintable = false,
|
||||
),
|
||||
"red" to
|
||||
NoteTint(
|
||||
@@ -175,3 +250,75 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
|
||||
|
||||
/**
|
||||
* The tint for a NOTE, which is not the same thing as looking up its stored key: a
|
||||
* note that has no colour of its own gets one derived from its id, so that a board
|
||||
* of untagged notes reads as individual items rather than a wall of white.
|
||||
*
|
||||
* See `DerivedTint.kt` — the rule lives there, free of Compose, so it can be tested.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun noteTintFor(note: Note): NoteTint =
|
||||
noteTint(resolvedNoteColor(note.id, note.color, firstLabelColor(note), NOTE_TINTS.keys))
|
||||
|
||||
/**
|
||||
* Whether this note's colour was chosen rather than generated — see [noteCardColor].
|
||||
*
|
||||
* Not `@Composable`: the card needs it alongside `isSystemInDarkTheme()`, and keeping
|
||||
* it an ordinary function means it can be read anywhere the note is.
|
||||
*/
|
||||
private fun noteIsStrong(note: Note): Boolean = noteColorIsChosen(note.color, firstLabelColor(note), NOTE_TINTS.keys)
|
||||
|
||||
/**
|
||||
* The single answer to "what colour is this card", from either of the two sources.
|
||||
*
|
||||
* A tagged note takes its tag's colour out of [NOTE_TINTS]; an untagged one gets a
|
||||
* fill generated from its id, which is not a palette key at all. Callers ask this
|
||||
* rather than choosing between them, so the two paths cannot drift apart between the
|
||||
* board and the editor.
|
||||
*
|
||||
* A draft carries DRAFT_ID ("") and stays on the plain surface — there is no identity
|
||||
* to derive from yet, and hashing the empty string would give every draft the same
|
||||
* fill and then change it at save time anyway.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun noteCardColor(
|
||||
note: Note,
|
||||
dark: Boolean,
|
||||
): Color =
|
||||
when {
|
||||
noteIsStrong(note) -> noteTintFor(note).chosenBackground(dark)
|
||||
note.id.isEmpty() -> NOTE_TINTS.getValue("default").background(dark)
|
||||
else -> Color(derivedFillArgb(note.id, dark))
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour of the note's FIRST label, already resolved, or "" when it has none.
|
||||
*
|
||||
* First rather than any other: it is the one the person controls by typing, where
|
||||
* alphabetical or most-used would move a note's colour when an unrelated tag was
|
||||
* added somewhere else. Manual labels count the same as `#tags` — someone looking at
|
||||
* a chip cannot tell which kind they made, and two identically-tagged notes in
|
||||
* different colours for an invisible reason is worse than the rule being loose.
|
||||
*/
|
||||
private fun firstLabelColor(note: Note): String {
|
||||
val first = note.labels.firstOrNull() ?: return ""
|
||||
return resolvedLabelColor(first.name, first.color, NOTE_TINTS.keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tint for a LABEL, derived from its name when nobody has picked one.
|
||||
*
|
||||
* Every `#tag` is born colourless, so without this a board of tags is a board of
|
||||
* identical grey chips. See `DerivedTint.kt` for why this derives rather than
|
||||
* persisting a colour when the tag is minted.
|
||||
*/
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun labelTintFor(
|
||||
name: String,
|
||||
color: String,
|
||||
): NoteTint = noteTint(resolvedLabelColor(name, color, NOTE_TINTS.keys))
|
||||
|
||||
@@ -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,11 +63,21 @@ fun PlainTextField(
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
visualTransformation = visualTransformation,
|
||||
colors =
|
||||
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.
|
||||
// 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,
|
||||
@@ -70,6 +85,4 @@ fun PlainTextField(
|
||||
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,59 @@ 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.
|
||||
*
|
||||
* Only ever shown once the build is DOWNLOADED, so the offer is a single tap rather
|
||||
* than the start of a wait — and so nothing is said at all until the app has been on
|
||||
* wifi, which is where the fetch happens.
|
||||
*
|
||||
* Dismissible, but not permanently. "Later" clears it for this sitting; the next time
|
||||
* the app comes forward it says so again. That is the difference between a reminder
|
||||
* and a notice you can lose.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateBanner(
|
||||
version: String,
|
||||
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(
|
||||
text = stringResource(R.string.update_banner_ready, 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,28 @@ 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.
|
||||
*
|
||||
* Gated on [ready], so the banner never appears until the bytes are on disk. An
|
||||
* update that has been FOUND is not news anyone can act on quickly — offering it
|
||||
* off wifi would turn one tap into a download somebody did not plan.
|
||||
*
|
||||
* Deliberately still true while [working]: the install is the one moment the
|
||||
* banner has something to report, and hiding it there would look like the tap
|
||||
* did nothing.
|
||||
*/
|
||||
val nagging: Boolean get() = ready && available != null && !nagDismissed
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,28 +71,103 @@ 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()
|
||||
when {
|
||||
state.busy -> Unit
|
||||
|
||||
// Already fetched and waved away — say so again. "Later" is for that
|
||||
// sitting, not forever, and without this branch a single dismissal would
|
||||
// silence the update permanently. Which is precisely the "lost" this whole
|
||||
// path exists to prevent.
|
||||
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
|
||||
|
||||
// Found one and never fetched it — almost always because the last look
|
||||
// happened on mobile data. Retry the FETCH rather than the check, and
|
||||
// ignore the interval: this is what makes an update found on the train
|
||||
// arrive when the person gets home instead of waiting out six hours.
|
||||
state.available != null ->
|
||||
if (AppUpdate.onWifi(context)) viewModelScope.launch { download() }
|
||||
|
||||
// Flicking between two apps is not a request to re-check.
|
||||
now - lastCheckAt < CHECK_INTERVAL_MS -> Unit
|
||||
|
||||
else -> {
|
||||
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 before anything is said, so the banner is a one-tap install
|
||||
// rather than the start of a wait. Off wifi this simply does not happen
|
||||
// and the app stays quiet — the next foreground on wifi picks it up.
|
||||
if (fetch && state.available != null && AppUpdate.onWifi(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 for this sitting. The next trip to the foreground says 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 +176,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 +219,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,8 @@
|
||||
<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_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>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the derived-tint rule against `frontend/src/notes/colors.ts`.
|
||||
*
|
||||
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
|
||||
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
|
||||
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
|
||||
* say so and a note will simply be a different colour on the phone than in the
|
||||
* browser. The same four ids and hashes are written into colors.ts as a comment;
|
||||
* changing either side means changing both and re-checking here.
|
||||
*/
|
||||
class DerivedTintTest {
|
||||
@Test
|
||||
fun `hashes match the fixture shared with the web`() {
|
||||
// Kotlin's Int is signed, so the two hashes above 0x7FFFFFFF are written as
|
||||
// their negative literal. The unsigned value in the comment is what colors.ts
|
||||
// records and what an implementation of FNV-1a will actually produce.
|
||||
assertEquals(-0x41B8712F, tintHash("00000000-0000-0000-0000-000000000000")) // 0xbe478ed1
|
||||
assertEquals(0x3D75CC01, tintHash("11111111-1111-1111-1111-111111111111"))
|
||||
assertEquals(-0x0EF71AD0, tintHash("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) // 0xf108e530
|
||||
assertEquals(0x5B651540, tintHash("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tints match the fixture shared with the web`() {
|
||||
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
|
||||
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
|
||||
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
|
||||
assertEquals("orange", derivedTint("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||
}
|
||||
|
||||
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
|
||||
* index out of the list for those. The bug this catches is a crash, not a wrong
|
||||
* colour, so it is worth more than one id's worth of coverage. */
|
||||
@Test
|
||||
fun `every tint is a real palette key, over many ids`() {
|
||||
for (n in 0 until 2000) {
|
||||
val tint = derivedTint("note-$n")
|
||||
assertEquals(true, tint in DERIVED_TINT_KEYS)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived palette excludes default`() {
|
||||
assertEquals(false, "default" in DERIVED_TINT_KEYS)
|
||||
assertEquals(9, DERIVED_TINT_KEYS.size)
|
||||
}
|
||||
|
||||
/** The order IS the mapping — reordering silently recolours every untagged note
|
||||
* on one surface only. Written out longhand so a reorder fails here loudly. */
|
||||
@Test
|
||||
fun `key order matches colors ts`() {
|
||||
assertEquals(
|
||||
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"),
|
||||
DERIVED_TINT_KEYS,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicitly picked colour still wins`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
assertEquals("teal", resolvedNoteColor("any-id", "teal", "", known))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown colour key falls back to the derived tint`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val id = "00000000-0000-0000-0000-000000000000"
|
||||
assertEquals("purple", resolvedNoteColor(id, "chartreuse", "", known))
|
||||
}
|
||||
|
||||
/** `default` is not a choice, it is the absence of one — so a note stored as
|
||||
* `default` gets a derived tint rather than staying white. That is the whole
|
||||
* point of the change. */
|
||||
@Test
|
||||
fun `a default colour is treated as no colour`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val id = "11111111-1111-1111-1111-111111111111"
|
||||
assertEquals("blue", resolvedNoteColor(id, "default", "", known))
|
||||
assertNotEquals("default", resolvedNoteColor(id, "default", "", known))
|
||||
}
|
||||
|
||||
/** A draft has no id yet. It must not be hashed — see the comment in DerivedTint. */
|
||||
@Test
|
||||
fun `a draft stays default until it has an id`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
assertEquals("default", resolvedNoteColor("", "", "", known))
|
||||
assertEquals("default", resolvedNoteColor("", "default", "", known))
|
||||
}
|
||||
|
||||
/** The point of the whole milestone: notes sharing a tag share a colour, however
|
||||
* different their ids. Four `#todo` notes in four colours is what started this. */
|
||||
@Test
|
||||
fun `notes sharing a tag share a colour whatever their ids`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val todo = resolvedLabelColor("todo", "default", known)
|
||||
val a = resolvedNoteColor("11111111-1111-1111-1111-111111111111", "default", todo, known)
|
||||
val b = resolvedNoteColor("6ba7b810-9dad-11d1-80b4-00c04fd430c8", "default", todo, known)
|
||||
assertEquals(todo, a)
|
||||
assertEquals(a, b)
|
||||
// ...and it is NOT what either id would have derived on its own.
|
||||
assertNotEquals(derivedTint("11111111-1111-1111-1111-111111111111"), a)
|
||||
}
|
||||
|
||||
/** Until step 5 removes the picker, a hand-picked colour outranks the tag. */
|
||||
@Test
|
||||
fun `an explicit note colour still beats its tag`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val todo = resolvedLabelColor("todo", "default", known)
|
||||
assertNotEquals("teal", todo)
|
||||
assertEquals("teal", resolvedNoteColor("any-id", "teal", todo, known))
|
||||
}
|
||||
|
||||
/** Strength is not a second decision — it IS whether the colour was chosen. */
|
||||
@Test
|
||||
fun `only a chosen colour is drawn strongly`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val todo = resolvedLabelColor("todo", "default", known)
|
||||
assertEquals(true, noteColorIsChosen("default", todo, known))
|
||||
assertEquals(true, noteColorIsChosen("teal", "", known))
|
||||
assertEquals(false, noteColorIsChosen("default", "", known))
|
||||
assertEquals(false, noteColorIsChosen("", "", known))
|
||||
// An unrecognised key is not a choice — it is data we could not read.
|
||||
assertEquals(false, noteColorIsChosen("chartreuse", "", known))
|
||||
}
|
||||
|
||||
/** A tag with no colour of its own derives one from its NAME, which is what makes
|
||||
* every `#todo` note the same colour rather than nine different ones. */
|
||||
@Test
|
||||
fun `a label with no colour derives one from its name`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val todo = resolvedLabelColor("todo", "default", known)
|
||||
assertEquals(derivedTint("todo"), todo)
|
||||
assertNotEquals("default", todo)
|
||||
}
|
||||
|
||||
/** Tags dedupe case-insensitively, so `#Todo` and `#todo` are one tag and must not
|
||||
* be two colours. This is the whole reason the name is lowercased first. */
|
||||
@Test
|
||||
fun `label colour ignores case`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
assertEquals(
|
||||
resolvedLabelColor("todo", "default", known),
|
||||
resolvedLabelColor("ToDo", "default", known),
|
||||
)
|
||||
}
|
||||
|
||||
/** `teal` deliberately, NOT the colour "todo" derives to (pink) — asserting the
|
||||
* derived value here would pass even with the explicit branch deleted. */
|
||||
@Test
|
||||
fun `an explicitly picked label colour still wins`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
assertNotEquals("teal", derivedTint("todo"))
|
||||
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
|
||||
}
|
||||
|
||||
/**
|
||||
* The spread is real, and collisions are real too.
|
||||
*
|
||||
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
|
||||
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
|
||||
* both green. Colour is a hint that two notes are related, never a claim that they
|
||||
* carry the same tag; the chip's TEXT is what says which tag it is.
|
||||
*/
|
||||
@Test
|
||||
fun `different tag names spread across the palette`() {
|
||||
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||
val names = listOf("todo", "grocery", "work", "home", "ideas", "reading", "urgent")
|
||||
val colours = names.map { resolvedLabelColor(it, "default", known) }
|
||||
assertEquals(true, colours.toSet().size >= 5)
|
||||
}
|
||||
|
||||
/** The reason the feature exists: two adjacent notes should not look identical. */
|
||||
@Test
|
||||
fun `the tint spreads across the palette`() {
|
||||
val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
|
||||
assertEquals(DERIVED_TINT_KEYS.size, seen.size)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// derivedFillArgb — the generated fill an UNTAGGED note wears.
|
||||
//
|
||||
// A different job from the palette above, and pinned separately. The palette's
|
||||
// nine keys mean "which tag"; this means nothing at all and exists so a board is
|
||||
// not a monolithic wall. It was nine keys once, and measured, those nine dark
|
||||
// fills sat within a 1.03 contrast of one another — nine tints that looked like
|
||||
// three. These are the tests that stop that happening again.
|
||||
|
||||
/** The web has no test runner and cannot even be EXECUTED on the dev machine
|
||||
* (no node), so this fixture is the only place the two implementations are ever
|
||||
* actually compared. The same four ids and hexes are a comment in colors.ts. */
|
||||
@Test
|
||||
fun `generated fills match the fixture shared with the web`() {
|
||||
assertEquals("#1e3130", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = true)))
|
||||
assertEquals("#1a2818", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = true)))
|
||||
assertEquals("#162419", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = true)))
|
||||
assertEquals("#311e20", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", dark = true)))
|
||||
|
||||
assertEquals("#f3fcfb", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = false)))
|
||||
assertEquals("#fbfefb", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = false)))
|
||||
assertEquals("#ffffff", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = false)))
|
||||
assertEquals("#fcf3f4", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", dark = false)))
|
||||
}
|
||||
|
||||
/** The whole reason this replaced the nine-key ramp. Two orders of magnitude more
|
||||
* fills than the palette could offer, so a board of any size stops repeating. */
|
||||
@Test
|
||||
fun `the generated fill spreads far wider than the palette ever could`() {
|
||||
val dark = (0 until 500).map { derivedFillArgb("note-$it", dark = true) }.toSet()
|
||||
val light = (0 until 500).map { derivedFillArgb("note-$it", dark = false) }.toSet()
|
||||
assertEquals(true, dark.size > 200)
|
||||
assertEquals(true, light.size > 100)
|
||||
// ...and far more than the nine it replaced, which is the actual claim.
|
||||
assertEquals(true, dark.size > DERIVED_TINT_KEYS.size * 20)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightness is the axis that carries the variety, so it is the one worth pinning.
|
||||
*
|
||||
* The bug this catches is the one that shipped: a ramp that varies hue while
|
||||
* holding lightness fixed reads as one card repeated, because the eye separates
|
||||
* by lightness first. If someone collapses these levels again, the fills will
|
||||
* still all be "different colours" and the board will still be a wall.
|
||||
*/
|
||||
@Test
|
||||
fun `generated fills vary in lightness, not only in hue`() {
|
||||
val levels = (0 until 500).map { luminance(derivedFillArgb("note-$it", dark = true)) }.toSet()
|
||||
assertEquals(true, levels.size >= 6)
|
||||
assertEquals(true, levels.max() / levels.min() > 2.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing may be darker than the plain card surface (`neutral-900`, #171717) in
|
||||
* dark, or the note reads as a hole in the board rather than a card on it.
|
||||
*
|
||||
* THIS TEST HAS ALREADY EARNED ITS KEEP. The first floor was 0.090 — `neutral-900`'s
|
||||
* own HSL lightness — on the reasoning that starting at the card surface and
|
||||
* climbing could not possibly go below it. That reasoning confuses HSL lightness
|
||||
* with luminance. At one fixed lightness the eye sees very different brightnesses
|
||||
* by hue, because green carries 71% of the luminance formula and blue 7%: at 0.090
|
||||
* a yellow measures 0.0118 and a blue 0.0061, so every blue-ish untagged note was
|
||||
* 1.41x darker than the card it was supposed to match. Solved for, the floor is
|
||||
* 0.113.
|
||||
*
|
||||
* Which is why this asserts on LUMINANCE and not on the input lightness — testing
|
||||
* the number that was put in would have agreed with the bug.
|
||||
*/
|
||||
@Test
|
||||
fun `no generated fill sinks below the card surface`() {
|
||||
val surface = luminance(0xFF171717.toInt())
|
||||
for (n in 0 until 500) {
|
||||
assertEquals(true, luminance(derivedFillArgb("note-$n", dark = true)) >= surface)
|
||||
}
|
||||
// The worst case is a HUE — blue, around 240 — and 500 ids are not enough to
|
||||
// be sure of having visited it at the darkest level. There are 360 x 6 = 2160
|
||||
// reachable combinations; by the coupon-collector bound ~16,600 draws covers
|
||||
// them, so 40,000 makes a miss vanishingly unlikely. Cheap on the JVM, and the
|
||||
// alternative is a test that would have PASSED against the original bug.
|
||||
for (n in 0 until 40_000) {
|
||||
assertEquals(true, luminance(derivedFillArgb("hue-sweep-$n", dark = true)) >= surface)
|
||||
}
|
||||
}
|
||||
|
||||
/** Body text is drawn on these. AA wants 4.5:1 and the meta row 3:1; the margin
|
||||
* here is enormous, and the test is what keeps it that way if the levels move. */
|
||||
@Test
|
||||
fun `every generated fill keeps body text well clear of AA`() {
|
||||
for (n in 0 until 500) {
|
||||
assertEquals(true, contrast(0xFFD4D4D4.toInt(), derivedFillArgb("note-$n", dark = true)) >= 4.5)
|
||||
assertEquals(true, contrast(0xFF404040.toInt(), derivedFillArgb("note-$n", dark = false)) >= 4.5)
|
||||
}
|
||||
}
|
||||
|
||||
/** Same id, same fill, forever — a note that changed colour on reload would read
|
||||
* as corruption. The point of hashing rather than rolling a die. */
|
||||
@Test
|
||||
fun `the generated fill is stable for an id`() {
|
||||
val id = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
assertEquals(derivedFillArgb(id, dark = true), derivedFillArgb(id, dark = true))
|
||||
assertNotEquals(derivedFillArgb(id, dark = true), derivedFillArgb(id, dark = false))
|
||||
}
|
||||
|
||||
/** Every fill is fully opaque. A translucent one would composite over whatever is
|
||||
* behind it, and the editor sheet is not the board — the same note would be two
|
||||
* colours depending on where you were looking at it. */
|
||||
@Test
|
||||
fun `generated fills are opaque`() {
|
||||
for (n in 0 until 100) {
|
||||
assertEquals(0xFF, (derivedFillArgb("note-$n", dark = true) ushr 24) and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hex(argb: Int): String = "#%06x".format(argb and 0xFFFFFF)
|
||||
|
||||
private fun channel(argb: Int, shift: Int): Double {
|
||||
val c = ((argb ushr shift) and 0xFF) / 255.0
|
||||
return if (c <= 0.03928) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
|
||||
/** WCAG relative luminance, so the assertions above are about what an eye sees
|
||||
* rather than about the bytes. */
|
||||
private fun luminance(argb: Int): Double =
|
||||
0.2126 * channel(argb, 16) + 0.7152 * channel(argb, 8) + 0.0722 * channel(argb, 0)
|
||||
|
||||
private fun contrast(
|
||||
a: Int,
|
||||
b: Int,
|
||||
): Double {
|
||||
val la = luminance(a)
|
||||
val lb = luminance(b)
|
||||
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
+126
-67
@@ -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,10 +435,13 @@ 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 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 {
|
||||
match k.as_str() {
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ import ImportNotes from "./ImportNotes.vue";
|
||||
import LabelsModal from "./LabelsModal.vue";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
import { facetsToQuery } from "../notes/facets";
|
||||
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||
import { NOTE_SWATCH_CLASSES, resolveLabelColor } from "../notes/colors";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -173,8 +173,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null));
|
||||
|
||||
function labelDot(color: string): string {
|
||||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
||||
// The drawer's tag list. Same resolution as every other chip and dot — a tag that is
|
||||
// green on a card must be green here, or the sidebar stops being a way to find it.
|
||||
function labelDot(label: { name: string; color: string }): string {
|
||||
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
|
||||
}
|
||||
|
||||
// The board lenses — the routes a search can happen *within*. Searching while looking
|
||||
@@ -443,7 +445,7 @@ async function signOut() {
|
||||
>
|
||||
<span
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
|
||||
:class="labelDot(lb.color)"
|
||||
:class="labelDot(lb)"
|
||||
></span>
|
||||
<span class="truncate">{{ lb.name }}</span>
|
||||
</RouterLink>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useLabelsStore, type Label } from "../stores/labels";
|
||||
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||
import {
|
||||
NOTE_COLOR_KEYS,
|
||||
NOTE_COLOR_LABELS,
|
||||
NOTE_SWATCH_CLASSES,
|
||||
resolveLabelColor,
|
||||
type NoteColor,
|
||||
} from "../notes/colors";
|
||||
import BaseModal from "./BaseModal.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
@@ -25,8 +31,12 @@ async function rename(id: string, value: string) {
|
||||
if (name) await labels.rename(id, name);
|
||||
}
|
||||
|
||||
function labelDot(color: string): string {
|
||||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
||||
// Shows the colour the tag ACTUALLY wears, derived from its name when nobody has
|
||||
// picked one — so this screen agrees with the chips everywhere else. The ring in the
|
||||
// swatch grid below follows the same resolution, so opening the picker highlights
|
||||
// what you can already see rather than nothing at all.
|
||||
function labelDot(label: { name: string; color: string }): string {
|
||||
return NOTE_SWATCH_CLASSES[resolveLabelColor(label)] ?? NOTE_SWATCH_CLASSES.default;
|
||||
}
|
||||
|
||||
function openColor(id: string) {
|
||||
@@ -75,8 +85,8 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
|
||||
:class="labelDot(lb.color)"
|
||||
:title="`Color: ${NOTE_COLOR_LABELS[(lb.color as NoteColor)] ?? lb.color}`"
|
||||
:class="labelDot(lb)"
|
||||
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
|
||||
aria-label="Change label color"
|
||||
@click="openColor(lb.id)"
|
||||
/>
|
||||
@@ -120,7 +130,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
:title="NOTE_COLOR_LABELS[key]"
|
||||
:aria-label="NOTE_COLOR_LABELS[key]"
|
||||
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
:class="[NOTE_SWATCH_CLASSES[key], lb.color === key ? 'ring-2 ring-brand' : '']"
|
||||
:class="[NOTE_SWATCH_CLASSES[key], resolveLabelColor(lb) === key ? 'ring-2 ring-brand' : '']"
|
||||
@click="pickColor(lb.id, key)"
|
||||
/>
|
||||
</div>
|
||||
@@ -140,7 +150,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
>
|
||||
<span
|
||||
class="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10 dark:border-white/15"
|
||||
:class="labelDot(t.color)"
|
||||
:class="labelDot(t)"
|
||||
></span>
|
||||
<span class="truncate">{{ t.name }}</span>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,17 +3,18 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import {
|
||||
LABEL_CHIP_CLASSES,
|
||||
NOTE_CARD_CLASSES,
|
||||
NOTE_COLOR_KEYS,
|
||||
NOTE_COLOR_LABELS,
|
||||
NOTE_SWATCH_CLASSES,
|
||||
noteCardClasses,
|
||||
noteTintVars,
|
||||
resolveLabelColor,
|
||||
type NoteColor,
|
||||
} from "../notes/colors";
|
||||
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 +95,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
|
||||
@@ -181,12 +191,10 @@ async function snoozeReminder(minutes: number): Promise<void> {
|
||||
emit("reminder-changed");
|
||||
}
|
||||
|
||||
function cardClass(color: NoteColor): string {
|
||||
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
|
||||
}
|
||||
|
||||
function labelChip(color: string): string {
|
||||
return LABEL_CHIP_CLASSES[color as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
||||
// Takes the label, not its colour: a tag nobody has coloured derives one from its
|
||||
// name, so chips carry the tag's identity rather than all being the same grey.
|
||||
function labelChip(label: { name: string; color: string }): string {
|
||||
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
|
||||
}
|
||||
|
||||
// Per-card color popover (recolor without opening the editor).
|
||||
@@ -215,17 +223,60 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
|
||||
class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
|
||||
:class="[
|
||||
cardClass(note.color),
|
||||
noteCardClasses(note),
|
||||
dragging ? 'opacity-40' : '',
|
||||
dragOver
|
||||
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
|
||||
: '',
|
||||
active ? 'ring-2 ring-brand' : '',
|
||||
]"
|
||||
:style="noteTintVars(note)"
|
||||
:data-note-id="note.id"
|
||||
>
|
||||
<!-- THE EDGE LIVES HERE, NOT IN THE PALETTE, and that is the whole point of it.
|
||||
Every card gets the same grey hairline whatever colour it is; the fill is the
|
||||
only thing that varies. The version of this that was a `{hue}-900` border
|
||||
failed because the line was both the loudest element on the card (1.56-2.09
|
||||
against its own fill, where the fill managed 1.03-1.05 against the board) AND
|
||||
carried the same information the fill did, so a board of them read as a grid
|
||||
of outlines. A neutral line carries no information at all, which is exactly
|
||||
what lets it be structure.
|
||||
|
||||
The two values are MATCHED, not picked by eye: each measures ~1.6-1.7 against
|
||||
the card it edges (light 1.57-1.98, dark 1.58-1.73), so the edge has the same
|
||||
authority in either theme. #404040 is `neutral-700`, which is what the default
|
||||
card's border always was — promoted from one entry in the palette to the rule
|
||||
for all of them. #b8b8b8 sits between `neutral-300` and `neutral-400`, neither
|
||||
of which lands in range: 300 fades out at 1.18 on a gray-tagged card, 400
|
||||
jumps to 2.52 and reads as a wireframe.
|
||||
|
||||
NOT a translucent black/white edge, which is the tidier-looking way to do this
|
||||
and was measured and rejected: a border composites over the card's own fill,
|
||||
so `border-white/20` comes out #56396d on a purple card and #a3c9c1 on a teal
|
||||
one. Hue-coded edges are the thing being removed.
|
||||
|
||||
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
|
||||
shadow is only depth. -->
|
||||
<!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
|
||||
the one thing that says what a note IS below the fold of a glance. A board is
|
||||
scanned, not read, and the answer to "which of these is about the thing I am
|
||||
looking for" should be the first thing the eye lands on rather than the last.
|
||||
|
||||
Above the image and the body rather than beside them, because the body's first
|
||||
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
|
||||
compete with the thing that identifies the note. -->
|
||||
<div v-if="note.labels.length" class="mb-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="lb in note.labels"
|
||||
:key="lb.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="labelChip(lb)"
|
||||
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-if="firstImage"
|
||||
:src="firstImage.url"
|
||||
@@ -263,7 +314,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,23 +330,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)"
|
||||
/>
|
||||
|
||||
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="lb in note.labels"
|
||||
:key="lb.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="labelChip(lb.color)"
|
||||
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
|
||||
>
|
||||
</div>
|
||||
<!-- 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.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<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 { LABEL_CHIP_CLASSES, resolveLabelColor, 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) {
|
||||
@@ -279,20 +358,26 @@ async function onLabelsChange(next: NoteLabel[]) {
|
||||
async function removeLabel(id: string) {
|
||||
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
|
||||
}
|
||||
function labelChip(c: string): string {
|
||||
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
||||
// Takes the label, not its colour — a tag nobody has coloured derives one from its
|
||||
// name. Must match NoteCard's chip exactly: the same tag either side of opening a
|
||||
// note changing colour would be worse than both being grey.
|
||||
function labelChip(label: { name: string; color: string }): string {
|
||||
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
|
||||
}
|
||||
|
||||
// ---- 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 +456,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,29 +559,59 @@ function revPreview(rev: NoteRevision): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- The body, as fields and checkboxes rather than as markup. A checklist
|
||||
item is a real input; a run of prose is one textarea, so typing a
|
||||
paragraph still feels like typing a paragraph. -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<template v-for="(block, i) in blocks" :key="block.id">
|
||||
<div v-if="block.checked !== null" class="group/item flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 shrink-0 accent-brand"
|
||||
:checked="block.checked"
|
||||
:aria-label="block.text || 'Checklist item'"
|
||||
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<input
|
||||
:ref="(el) => setBlockEl(block.id, el)"
|
||||
:value="block.text"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
|
||||
:class="block.checked ? 'text-neutral-400 line-through' : ''"
|
||||
@input="setText(i, ($event.target as HTMLInputElement).value)"
|
||||
@keydown.enter.prevent="onTaskEnter(i)"
|
||||
@keydown.backspace="onTaskBackspace(i, $event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
|
||||
aria-label="Delete item"
|
||||
@click="removeBlock(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
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
|
||||
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
|
||||
v-for="lb in labelList"
|
||||
:key="lb.id"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="labelChip(lb.color)"
|
||||
:class="labelChip(lb)"
|
||||
>
|
||||
{{ lb.via_tag ? "#" + lb.name : lb.name }}
|
||||
<button
|
||||
@@ -598,7 +713,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 };
|
||||
}
|
||||
+331
-23
@@ -17,18 +17,10 @@ export const NOTE_COLOR_KEYS = [
|
||||
|
||||
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
|
||||
|
||||
export const NOTE_CARD_CLASSES: Record<NoteColor, string> = {
|
||||
default: "bg-white border-neutral-200 dark:bg-neutral-900 dark:border-neutral-700",
|
||||
red: "bg-red-50 border-red-200 dark:bg-red-950/40 dark:border-red-900",
|
||||
orange: "bg-orange-50 border-orange-200 dark:bg-orange-950/40 dark:border-orange-900",
|
||||
yellow: "bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-900",
|
||||
green: "bg-green-50 border-green-200 dark:bg-green-950/40 dark:border-green-900",
|
||||
teal: "bg-teal-50 border-teal-200 dark:bg-teal-950/40 dark:border-teal-900",
|
||||
blue: "bg-blue-50 border-blue-200 dark:bg-blue-950/40 dark:border-blue-900",
|
||||
purple: "bg-purple-50 border-purple-200 dark:bg-purple-950/40 dark:border-purple-900",
|
||||
pink: "bg-pink-50 border-pink-200 dark:bg-pink-950/40 dark:border-pink-900",
|
||||
gray: "bg-neutral-100 border-neutral-300 dark:bg-neutral-800 dark:border-neutral-700",
|
||||
};
|
||||
/** Membership test for a colour key arriving from the server, which may be newer
|
||||
* than this client. Was a lookup in the subdued card table until that table was
|
||||
* deleted — an untagged note's fill is generated now, not chosen from a palette. */
|
||||
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
|
||||
|
||||
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
|
||||
default: "bg-white dark:bg-neutral-600",
|
||||
@@ -43,18 +35,26 @@ export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
|
||||
gray: "bg-neutral-400 dark:bg-neutral-500",
|
||||
};
|
||||
|
||||
// Label chip tints (bg + readable text), keyed by the same color vocabulary.
|
||||
// Label chip tints (bg + readable text + a hairline edge), keyed by the same color
|
||||
// vocabulary.
|
||||
//
|
||||
// The RING is not decoration. A tagged note takes its first tag's colour and is drawn
|
||||
// at that hue's `-100` — exactly what the chip uses as its fill — so in light mode the
|
||||
// chip measured a contrast ratio of 1.00 against the card it had itself coloured.
|
||||
// Perfectly invisible; the tag name read as loose text. An edge holds the pill's shape
|
||||
// against ANY background, where shifting the fill only moves which card it collides
|
||||
// with.
|
||||
export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
|
||||
default: "bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300",
|
||||
red: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300",
|
||||
orange: "bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-300",
|
||||
yellow: "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300",
|
||||
green: "bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300",
|
||||
teal: "bg-teal-100 text-teal-700 dark:bg-teal-950/50 dark:text-teal-300",
|
||||
blue: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300",
|
||||
purple: "bg-purple-100 text-purple-700 dark:bg-purple-950/50 dark:text-purple-300",
|
||||
pink: "bg-pink-100 text-pink-700 dark:bg-pink-950/50 dark:text-pink-300",
|
||||
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200",
|
||||
default: "bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300 ring-1 ring-inset ring-black/10 dark:ring-white/15",
|
||||
red: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300 ring-1 ring-inset ring-red-700/60 dark:ring-red-300/60",
|
||||
orange: "bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-300 ring-1 ring-inset ring-orange-700/60 dark:ring-orange-300/60",
|
||||
yellow: "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300 ring-1 ring-inset ring-amber-700/60 dark:ring-amber-300/60",
|
||||
green: "bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300 ring-1 ring-inset ring-green-700/60 dark:ring-green-300/60",
|
||||
teal: "bg-teal-100 text-teal-700 dark:bg-teal-950/50 dark:text-teal-300 ring-1 ring-inset ring-teal-700/60 dark:ring-teal-300/60",
|
||||
blue: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300 ring-1 ring-inset ring-blue-700/60 dark:ring-blue-300/60",
|
||||
purple: "bg-purple-100 text-purple-700 dark:bg-purple-950/50 dark:text-purple-300 ring-1 ring-inset ring-purple-700/60 dark:ring-purple-300/60",
|
||||
pink: "bg-pink-100 text-pink-700 dark:bg-pink-950/50 dark:text-pink-300 ring-1 ring-inset ring-pink-700/60 dark:ring-pink-300/60",
|
||||
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200 ring-1 ring-inset ring-neutral-700/60 dark:ring-neutral-200/60",
|
||||
};
|
||||
|
||||
// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes).
|
||||
@@ -84,3 +84,311 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
|
||||
pink: "Pink",
|
||||
gray: "Gray",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived tints — the colour a note has when nothing chose one for it.
|
||||
//
|
||||
// A board of `default` notes is a wall of white rectangles and the eye gets no
|
||||
// help telling one from the next. Every note now carries some tint; this is where
|
||||
// an untagged one gets it.
|
||||
//
|
||||
// "RANDOM" MEANS DERIVED. The operator asked for "random subdued colors", but a
|
||||
// tint rolled at render time would differ between the phone and the browser and
|
||||
// change on every reload. Hashing the note's id is deterministic, identical on
|
||||
// every surface, costs no column and no migration, and a note keeps its colour
|
||||
// for life — which is what "random" actually meant here.
|
||||
//
|
||||
// THIS IS HALF A MIRRORED PAIR. `android/.../ui/NoteTint.kt` computes the same
|
||||
// hash over the same key order, and the two must agree exactly or a note is one
|
||||
// colour on the phone and another in the browser. Same discipline as the
|
||||
// checklist grammar's three implementations, and the same reason: a value that
|
||||
// disagrees across surfaces is a bug you cannot unsee and cannot explain.
|
||||
//
|
||||
// The Kotlin side has a unit test pinning the fixture below. THIS SIDE HAS NO
|
||||
// MECHANICAL GUARD — the frontend has no test runner, only `vue-tsc --noEmit`.
|
||||
// If you change anything here, check it against the fixture by hand.
|
||||
|
||||
/** The tints a derived colour can land on: the palette minus `default`, which is
|
||||
* the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a
|
||||
* deliberate card against the board's `bg-neutral-50`, not as an absence. */
|
||||
export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
|
||||
(key) => key !== "default",
|
||||
);
|
||||
|
||||
/**
|
||||
* FNV-1a over the id's bytes, 32-bit.
|
||||
*
|
||||
* Chosen because both languages compute it identically in ten lines with no
|
||||
* library. Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has
|
||||
* no equivalent, and reimplementing Java's from memory in TypeScript is exactly
|
||||
* how a mirror drifts.
|
||||
*
|
||||
* `& 0xff` is a no-op for the ASCII of a UUID, and is kept because it states the
|
||||
* intent — this hashes BYTES, so the Kotlin side reading `id[i].code and 0xFF`
|
||||
* is the same function rather than a coincidence.
|
||||
*/
|
||||
export function tintHash(id: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash ^= id.charCodeAt(i) & 0xff;
|
||||
// Math.imul, not `*`: JS numbers are doubles and a 32-bit overflow would be
|
||||
// silently kept as precision instead of wrapping the way Kotlin's Int does.
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
|
||||
export function derivedTint(id: string): NoteColor {
|
||||
return DERIVED_TINT_KEYS[tintHash(id) % DERIVED_TINT_KEYS.length];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The fill for an UNTAGGED note, which is a different job from the palette above.
|
||||
//
|
||||
// The palette has nine keys and they MEAN something: a tag's colour. An untagged
|
||||
// note's fill means nothing at all — it exists so a board is not a monolithic wall.
|
||||
// Tying the second job to the first was the mistake. Nine keys is far too few for a
|
||||
// board of any size, and once the nine were subdued enough not to shout they became
|
||||
// indistinguishable from each other: measured, the nine dark fills were separated by
|
||||
// at most a 1.03 contrast ratio, which is to say not at all. Nine tints that look
|
||||
// like three is exactly the wall the tint was added to break up.
|
||||
//
|
||||
// So this hashes to a colour directly rather than to a key. 338 distinct fills in
|
||||
// dark, 193 in light, against nine.
|
||||
//
|
||||
// TWO AXES, AND THE SECOND ONE IS THE FIX. The old ramp varied hue while pinning
|
||||
// every fill to the same lightness — deliberately, so each would read as a card
|
||||
// against the board. But the eye separates by lightness first, so nine hues at one
|
||||
// lightness read as one card repeated. Varying lightness too is what makes the
|
||||
// difference; hue alone never could at this darkness.
|
||||
//
|
||||
// It is only SAFE to vary lightness because the card now has a grey edge of its own
|
||||
// (NoteCard.vue). While the fill was the only boundary the card had, it could not
|
||||
// afford to drift toward the board. The edge bought that freedom.
|
||||
//
|
||||
// MIRRORED in `android/.../ui/DerivedTint.kt`, which has the unit test. Same hash,
|
||||
// same levels, same rounding — see the fixture below.
|
||||
|
||||
/** Lightness steps a generated fill can land on. Six rather than three because the
|
||||
* levels are what carry the variety, and rather than twelve because past a point
|
||||
* they stop being distinguishable and only cost contrast headroom. */
|
||||
const TINT_LEVELS = 6;
|
||||
|
||||
// Saturation is FIXED, and that is what keeps this subtle whichever hue it lands on.
|
||||
// Variety comes from hue and lightness; loudness would come from saturation, so
|
||||
// saturation is the one dial the hash never touches.
|
||||
const DARK_SATURATION = 0.25;
|
||||
const LIGHT_SATURATION = 0.6;
|
||||
|
||||
// HSL LIGHTNESS IS NOT LUMINANCE, and the dark floor is set by the difference.
|
||||
//
|
||||
// The obvious floor is `neutral-900`'s own lightness, 0.090 — start at the plain card
|
||||
// surface and climb, so no note ever recedes into the board. That was the first
|
||||
// attempt and it was wrong: at a FIXED HSL lightness the eye sees wildly different
|
||||
// brightnesses by hue, because green carries 71% of the luminance formula and blue
|
||||
// only 7%. At L=0.090 a yellow measures 0.0118 and a blue 0.0061 — the blue landing
|
||||
// 1.41x DARKER than the card it was meant to match, so a sixth of the board would
|
||||
// have been holes rather than variety.
|
||||
//
|
||||
// 0.113 is the lowest floor at which every hue clears the card surface, solved for
|
||||
// rather than guessed: 1.11-1.71 against the board, where the old single level
|
||||
// managed 1.14. Light runs the other way, from white down past the `neutral-50`
|
||||
// board; a card slightly darker than the board still reads as one because the edge
|
||||
// says so, and near white the hue barely moves luminance at all so it needs no
|
||||
// equivalent correction.
|
||||
const DARK_LIGHTNESS = [0.113, 0.127, 0.141, 0.155, 0.169, 0.183];
|
||||
const LIGHT_LIGHTNESS = [1.0, 0.99, 0.98, 0.97, 0.96, 0.95];
|
||||
|
||||
/**
|
||||
* The opaque fill an untagged note wears, as `#rrggbb`. Stable for the note's life.
|
||||
*
|
||||
* Hue and level are read from DIFFERENT parts of the hash so a note's shade is not a
|
||||
* function of its hue — two notes of nearly the same hue should still be able to
|
||||
* differ in weight, which is half of where the variety comes from.
|
||||
*/
|
||||
export function derivedFill(id: string, dark: boolean): string {
|
||||
const hash = tintHash(id);
|
||||
const level = (hash >>> 16) % TINT_LEVELS;
|
||||
return hslHex(
|
||||
hash % 360,
|
||||
dark ? DARK_SATURATION : LIGHT_SATURATION,
|
||||
dark ? DARK_LIGHTNESS[level] : LIGHT_LIGHTNESS[level],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Textbook HSL to RGB, written out rather than pulled from a library because the
|
||||
* Kotlin side has to compute the same bytes and there is no library both can share.
|
||||
* Rounding is `floor(v + 0.5)` on both sides rather than the language's `round`:
|
||||
* Kotlin rounds half away from zero and JS rounds half up, which agree here, but
|
||||
* stating the rule leaves nothing for a future reader to have to check.
|
||||
*/
|
||||
function hslHex(hue: number, saturation: number, lightness: number): string {
|
||||
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const sector = hue / 60;
|
||||
const second = chroma * (1 - Math.abs((sector % 2) - 1));
|
||||
const match = lightness - chroma / 2;
|
||||
const ramps: [number, number, number][] = [
|
||||
[chroma, second, 0],
|
||||
[second, chroma, 0],
|
||||
[0, chroma, second],
|
||||
[0, second, chroma],
|
||||
[second, 0, chroma],
|
||||
[chroma, 0, second],
|
||||
];
|
||||
const [red, green, blue] = ramps[Math.floor(sector)];
|
||||
const byte = (v: number) =>
|
||||
Math.min(255, Math.max(0, Math.floor((v + match) * 255 + 0.5)))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${byte(red)}${byte(green)}${byte(blue)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour to paint a LABEL — its chip, and (step 3) every note carrying it.
|
||||
*
|
||||
* Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag`
|
||||
* ever typed is currently `default`: `notes/tags.py` mints one as
|
||||
* `Label(owner_id=…, name=name)` with no colour, so it takes the column default.
|
||||
* Tag-driven note colour against that would leave the board exactly as grey as it
|
||||
* was.
|
||||
*
|
||||
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965.
|
||||
* That plan wanted a hashed colour written at each of the four places a label can be
|
||||
* born — and named the risk itself: `find_or_create_label` is "easy to miss, and it
|
||||
* is the common one", because most tags are born from typing `#grocery`, not from a
|
||||
* management screen. Deriving has no mint points to miss, needs no backfill for the
|
||||
* tags that already exist, and reuses the hash the notes already use. The cost is
|
||||
* that renaming a tag recolours it, which is defensible: the name IS the tag.
|
||||
*
|
||||
* An explicitly-picked colour is still stored and still wins, so tag colours stay
|
||||
* editable exactly as asked.
|
||||
*
|
||||
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
|
||||
* the same tag and should not change colour. Both `toLowerCase` here and Kotlin's
|
||||
* `lowercase()` are locale-independent, so the mirror holds.
|
||||
*/
|
||||
export function resolveLabelColor(label: { name: string; color?: string | null }): NoteColor {
|
||||
const picked = label.color as NoteColor | undefined | null;
|
||||
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
|
||||
if (!label.name) return "default";
|
||||
return derivedTint(label.name.toLowerCase());
|
||||
}
|
||||
|
||||
// Fixture — the same ids and expected keys the Kotlin test asserts. Kept here as
|
||||
// prose because there is nowhere on this side to assert it. If you change the hash
|
||||
// or the key order, these four must still hold on BOTH surfaces:
|
||||
//
|
||||
// 00000000-0000-0000-0000-000000000000 0xbe478ed1 purple
|
||||
// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue
|
||||
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange
|
||||
// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 orange
|
||||
//
|
||||
// And for labels, which hash the lowercased NAME rather than an id:
|
||||
//
|
||||
// todo -> pink grocery -> blue work -> green home -> gray
|
||||
// ideas -> green reading -> gray urgent -> red
|
||||
//
|
||||
// And for derivedFill, which uses the same hash on two axes (dark / light):
|
||||
//
|
||||
// 00000000-0000-0000-0000-000000000000 hue 177 lev 3 #1e3130 #f3fcfb
|
||||
// 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #1a2818 #fbfefb
|
||||
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #162419 #ffffff
|
||||
// f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #311e20 #fcf3f4
|
||||
//
|
||||
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable
|
||||
// and it is not a bug: colour hints that two notes are related, it never claims they
|
||||
// carry the same tag. The chip's text is what says which tag it is.
|
||||
|
||||
// The FULL-strength ramp: what a note wears when its colour was CHOSEN — by a tag, or
|
||||
// (until step 5) by the picker. One Tailwind step deeper in light mode, and a much
|
||||
// heavier fill in dark.
|
||||
//
|
||||
// UNCHANGED by the border pass, deliberately. The operator's complaint was the edge
|
||||
// line and the loudness of the DERIVED tint; the chosen colours were "pretty well"
|
||||
// where they landed, and a ramp somebody has already signed off on is not something
|
||||
// to redo while fixing something else.
|
||||
//
|
||||
// THERE IS NO SECOND RAMP ANY MORE. This one is reached only by a note that HAS a
|
||||
// colour; a note without one gets a generated fill instead (`derivedFill`), because
|
||||
// nine palette keys could never carry both jobs. The palette says WHICH TAG. The
|
||||
// generator says nothing at all, and only has to keep the board from repeating.
|
||||
//
|
||||
// So these values do not need to be subtle and never did — a tagged note is making a
|
||||
// statement, and the quiet end of the board is now handled somewhere else entirely.
|
||||
export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
|
||||
default: "bg-white dark:bg-neutral-900",
|
||||
red: "bg-red-100 dark:bg-red-950/70",
|
||||
orange: "bg-orange-100 dark:bg-orange-950/70",
|
||||
yellow: "bg-amber-100 dark:bg-amber-950/70",
|
||||
green: "bg-green-100 dark:bg-green-950/70",
|
||||
teal: "bg-teal-100 dark:bg-teal-950/70",
|
||||
blue: "bg-blue-100 dark:bg-blue-950/70",
|
||||
purple: "bg-purple-100 dark:bg-purple-950/70",
|
||||
pink: "bg-pink-100 dark:bg-pink-950/70",
|
||||
gray: "bg-neutral-200 dark:bg-neutral-800/70",
|
||||
};
|
||||
|
||||
/**
|
||||
* The palette key a note was GIVEN, or null when nothing gave it one.
|
||||
*
|
||||
* Null is the interesting answer: it means the fill has to be generated, because the
|
||||
* note carries no statement about what it is. Everything downstream branches here.
|
||||
*
|
||||
* Resolution order, and why: an explicit pick beats a tag because it is the more
|
||||
* specific statement and the picker still exists. The FIRST label wins among tags —
|
||||
* it is the one the person controls by typing, where alphabetical or most-used would
|
||||
* move a note's colour when an unrelated tag was added somewhere else.
|
||||
*
|
||||
* Manual labels count the same as `#tags`. Someone looking at a chip cannot tell which
|
||||
* kind they made, and two identically-tagged notes in different colours for an
|
||||
* invisible reason is worse than the rule being slightly loose.
|
||||
*/
|
||||
export function chosenNoteColor(note: {
|
||||
color?: string | null;
|
||||
labels?: { name: string; color: string }[];
|
||||
}): NoteColor | null {
|
||||
const picked = note.color as NoteColor | undefined | null;
|
||||
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
|
||||
const first = note.labels?.[0];
|
||||
if (first) return resolveLabelColor(first);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The class list for a note card.
|
||||
*
|
||||
* A tagged note gets a palette class. An untagged one gets `note-tint`, whose fill
|
||||
* arrives through the custom properties in `noteTintVars` — see the rule in
|
||||
* style.css, which exists because an inline style cannot answer a media query and the
|
||||
* light and dark fills are two different generated colours.
|
||||
*/
|
||||
export function noteCardClasses(note: {
|
||||
id: string;
|
||||
color?: string | null;
|
||||
labels?: { name: string; color: string }[];
|
||||
}): string {
|
||||
const chosen = chosenNoteColor(note);
|
||||
return chosen ? NOTE_CARD_CLASSES_STRONG[chosen] : "note-tint";
|
||||
}
|
||||
|
||||
/**
|
||||
* The generated fill for an untagged note, as the two custom properties `note-tint`
|
||||
* reads — or undefined when the note has a colour of its own, or is a draft.
|
||||
*
|
||||
* A draft carries no id, so there is nothing to derive from; `note-tint`'s fallbacks
|
||||
* catch that and paint the plain card surface. Hashing the empty string instead would
|
||||
* give every draft the same fill and then change it at save time anyway.
|
||||
*/
|
||||
export function noteTintVars(note: {
|
||||
id: string;
|
||||
color?: string | null;
|
||||
labels?: { name: string; color: string }[];
|
||||
}): Record<string, string> | undefined {
|
||||
if (chosenNoteColor(note) || !note.id) return undefined;
|
||||
return {
|
||||
"--tint-light": derivedFill(note.id, false),
|
||||
"--tint-dark": derivedFill(note.id, true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -47,6 +47,26 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* An untagged note's fill is GENERATED from its id, not chosen from the palette —
|
||||
* see `derivedFill` in notes/colors.ts for why nine keys was never going to be
|
||||
* enough. That means it cannot be a Tailwind class, and it cannot be a plain inline
|
||||
* style either: light and dark are two different computed colours and an inline
|
||||
* style has no way to answer a media query. So the card publishes both as custom
|
||||
* properties and this rule picks between them.
|
||||
*
|
||||
* The fallbacks are the draft case. A note with no id yet has nothing to hash, so it
|
||||
* publishes no properties and lands on the plain card surface — one colour change at
|
||||
* save time, rather than every draft sharing a fill and then changing anyway. */
|
||||
.note-tint {
|
||||
background-color: var(--tint-light, #ffffff);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.note-tint {
|
||||
background-color: var(--tint-dark, #171717);
|
||||
}
|
||||
}
|
||||
|
||||
/* Motion is a feature, not a given. Anyone whose OS says "reduce motion" has told
|
||||
* us something about vestibular comfort or attention, and the answer is to arrive
|
||||
* instantly rather than to animate faster.
|
||||
|
||||
@@ -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