Compare commits
18
Commits
ad21eac5bc
...
v26.08.23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3e29e4f6 | ||
|
|
c851b901df | ||
|
|
abe01da5f7 | ||
|
|
09b5f874b6 | ||
|
|
a85c53ba2c | ||
|
|
2141a0ac45 | ||
|
|
1aca294b95 | ||
|
|
7033995975 | ||
|
|
de72d27bd4 | ||
|
|
c99cbb3e14 | ||
|
|
6f21db85a1 | ||
|
|
924ddb20db | ||
|
|
95aa10c2c3 | ||
|
|
6d778f26a7 | ||
|
|
33e9278975 | ||
|
|
c46a4a7709 | ||
|
|
229076c82d | ||
|
|
867405fae2 |
@@ -39,6 +39,16 @@ POSTGRES_PASSWORD=
|
||||
# 127.0.0.1 so only the proxy can talk to it.
|
||||
#THOUGHTSYNC_BIND=0.0.0.0
|
||||
|
||||
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
|
||||
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
|
||||
# reverse proxy terminating HTTPS) and belongs there because it is something you may
|
||||
# need to change while the server is running, alongside the sign-in limits.
|
||||
|
||||
# How much the app says. Credential events (sign-ins, failures, throttles, new
|
||||
# accounts, device tokens issued) are logged at INFO and read with
|
||||
# `docker compose logs app`.
|
||||
#THOUGHTSYNC_LOG_LEVEL=INFO
|
||||
|
||||
# Database identity. Changing these AFTER the first start does not rename anything
|
||||
# that already exists — the volume keeps whatever the first run created.
|
||||
#POSTGRES_USER=thoughtsync
|
||||
|
||||
@@ -184,7 +184,80 @@ jobs:
|
||||
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q
|
||||
# DB-free by design. Anything needing a real Postgres is marked `integration`
|
||||
# and runs in the job below.
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||
|
||||
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
|
||||
# for the first time when the operator's container started — 26 revisions, none of
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# discovery step below filters `docker ps` by it. Service hostnames are not routable
|
||||
# on this runner (rule 79), so the step resolves the container's bridge IP.
|
||||
integration:
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
services:
|
||||
postgres:
|
||||
# Same image the production compose runs, so the schema is proven against the
|
||||
# Postgres it will actually meet.
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: thoughtsync
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: thoughtsync_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U thoughtsync"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Create virtual environment
|
||||
run: uv venv /opt/venv
|
||||
|
||||
# Same install as the unit lane — the two must agree on versions, or
|
||||
# "unit green, integration red" stops being a signal about the code.
|
||||
- name: Install package with dev deps
|
||||
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||
|
||||
- name: Integration suite (resolve service IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for the name filter) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1)
|
||||
test -n "$PG"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test"
|
||||
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
|
||||
# no bash /dev/tcp — so use the Python that is always present here.
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
for _ in range(30):
|
||||
try:
|
||||
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
else:
|
||||
sys.exit("postgres did not become reachable")
|
||||
PY
|
||||
# Real migrations build the schema, never metadata.create_all (rule 82) —
|
||||
# testing a schema no deployment has ever seen would prove nothing. This
|
||||
# step IS the migration test: a broken revision fails the job here.
|
||||
/opt/venv/bin/alembic upgrade head
|
||||
/opt/venv/bin/python -m pytest tests/ -v -m integration
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
|
||||
Generated
+1
-1
@@ -4189,7 +4189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thoughtsync-desktop"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
|
||||
|
||||
Revision ID: 0025
|
||||
Revises: 0024
|
||||
Create Date: 2026-08-22
|
||||
|
||||
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared
|
||||
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an
|
||||
ordinary child table keyed by note_id, serialization always emitted `items` whatever
|
||||
the kind, and the Android editor already toggled between the two losslessly. The
|
||||
storage has modelled "a body plus optional checkable items" the whole time; only the
|
||||
gates forbade it.
|
||||
|
||||
Nothing is lost. Items were already rows in their own table, and a note that was
|
||||
`kind = 'list'` keeps every one of them — it just stops being a different sort of
|
||||
thing from the note next to it.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0025"
|
||||
down_revision = "0024"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("notes", "kind")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# server_default so existing rows get a value; every note comes back as 'text',
|
||||
# which is right — a restored note with items would previously have hidden its
|
||||
# body, and there is no record of which ones were once lists.
|
||||
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""drop notes.title and note_revisions.title — a note's name is its first line
|
||||
|
||||
Revision ID: 0026
|
||||
Revises: 0025
|
||||
Create Date: 2026-08-22
|
||||
|
||||
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
|
||||
non-empty line of that body, falling back to its first checklist item. There is no
|
||||
separate field to type into, and `display_title` (already persisted, already what
|
||||
search results and export filenames read) carries the name.
|
||||
|
||||
## The search vector has to be rebuilt, not just left alone
|
||||
|
||||
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
|
||||
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
|
||||
column depends on, and even if it didn't, the weighting would be wrong. So it is
|
||||
dropped and recreated over `display_title` instead, which keeps the original
|
||||
intent: the note's NAME ranks above the rest of its body.
|
||||
|
||||
Rebuilding a stored generated column re-computes every row, and the GIN index is
|
||||
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
|
||||
before running this against something large.
|
||||
|
||||
## What happens to existing titles
|
||||
|
||||
Nothing preserves them, deliberately: `display_title` was already derived from the
|
||||
title when one was set, so every note keeps the NAME it had. What is lost is the
|
||||
distinction between "this note has an explicit title" and "this note's first line is
|
||||
its name" — which is the distinction being removed.
|
||||
|
||||
Imports are the exception and are handled in code, not here: a Keep note's title, or
|
||||
one in an export taken before this, is folded in as the note's first body line rather
|
||||
than dropped (see `_create_imported_note`).
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0026"
|
||||
down_revision = "0025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Order matters: the generated column depends on `title`, so it goes first.
|
||||
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||
|
||||
op.drop_column("notes", "title")
|
||||
op.drop_column("note_revisions", "title")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(display_title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||
) STORED
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||
|
||||
# Comes back empty. The text is not gone — it is the first line of every body —
|
||||
# but which notes once had an explicit title is not recorded anywhere.
|
||||
op.add_column("notes", sa.Column("title", sa.Text(), nullable=True))
|
||||
op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True))
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||
) STORED
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||
@@ -226,8 +226,8 @@ private fun App(
|
||||
ComposeSheet(
|
||||
saving = board.state.saving,
|
||||
onDismiss = { composing = false },
|
||||
onSave = { kind, title, content ->
|
||||
board.create(kind, title, content)
|
||||
onSave = { content ->
|
||||
board.create(content)
|
||||
composing = false
|
||||
},
|
||||
)
|
||||
|
||||
@@ -50,9 +50,6 @@ sealed interface Destination {
|
||||
) : Destination
|
||||
}
|
||||
|
||||
/** What kind of thing the compose sheet is making. */
|
||||
enum class DraftKind { NOTE, LIST }
|
||||
|
||||
/** Everything the board renders from, in one immutable snapshot. */
|
||||
data class BoardState(
|
||||
val destination: Destination = Destination.Notes,
|
||||
@@ -197,20 +194,15 @@ class BoardViewModel(
|
||||
* Blank input is ignored rather than rejected: an empty save is a slip, not a
|
||||
* mistake worth interrupting someone over.
|
||||
*/
|
||||
fun create(
|
||||
kind: DraftKind,
|
||||
title: String,
|
||||
content: String,
|
||||
) {
|
||||
val cleanTitle = title.trim()
|
||||
fun create(content: String) {
|
||||
val cleanContent = content.trim()
|
||||
if (cleanTitle.isEmpty() && cleanContent.isEmpty()) return
|
||||
if (cleanContent.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
state = state.copy(saving = true)
|
||||
state =
|
||||
try {
|
||||
val created = withContext(Dispatchers.IO) { core.createNote(draft(kind, cleanTitle, cleanContent)) }
|
||||
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
|
||||
@@ -281,34 +273,13 @@ class BoardViewModel(
|
||||
EditorAction.Close -> state = state.copy(editing = null)
|
||||
EditorAction.DismissError -> dismissError()
|
||||
|
||||
// Text is the only edit that batches: title and body are typed
|
||||
// together and saved together on close, so they cost one write and
|
||||
// one revision snapshot rather than two of each.
|
||||
// Saved on close rather than per keystroke, so a session of typing
|
||||
// costs one write and one revision snapshot.
|
||||
is EditorAction.SaveText ->
|
||||
mutate {
|
||||
it.updateNote(
|
||||
id,
|
||||
listOf(
|
||||
// An emptied title CLEARS the column rather than
|
||||
// storing "". The core derives `display_title` from
|
||||
// the first body line when the title is null, so the
|
||||
// difference is whether an untitled note is nameable
|
||||
// or blank — exactly what `ClearTitle` exists for.
|
||||
if (action.title.isBlank()) {
|
||||
NoteEdit.ClearTitle
|
||||
} else {
|
||||
NoteEdit.Title(action.title.trim())
|
||||
},
|
||||
NoteEdit.Body(action.body),
|
||||
),
|
||||
)
|
||||
}
|
||||
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||
|
||||
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
|
||||
|
||||
EditorAction.ToggleKind ->
|
||||
edit(id, NoteEdit.Kind(if (note.kind == KIND_LIST) KIND_TEXT else KIND_LIST))
|
||||
|
||||
// Pinning re-sorts the board rather than emptying it, and on a phone
|
||||
// you often pin while still reading — so unlike the three below, it
|
||||
// deliberately leaves the editor open.
|
||||
@@ -331,6 +302,10 @@ 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) }
|
||||
@@ -467,26 +442,8 @@ private fun query(
|
||||
labelId: String? = null,
|
||||
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
|
||||
|
||||
private fun draft(
|
||||
kind: DraftKind,
|
||||
title: String,
|
||||
content: String,
|
||||
): NoteDraft =
|
||||
when (kind) {
|
||||
// Body left to carry the text; the core derives display_title from its
|
||||
// first line when no title was given, so a captured thought is nameable
|
||||
// without making the user name it.
|
||||
DraftKind.NOTE ->
|
||||
NoteDraft(title = title, body = content, color = DEFAULT_COLOR, kind = null, items = null)
|
||||
// One line per item. At CAPTURE time the whole list is already in your
|
||||
// head, so typing it in one go beats a tap between each row; the editor
|
||||
// has the per-row control for when the list is being revised instead.
|
||||
DraftKind.LIST ->
|
||||
NoteDraft(
|
||||
title = title,
|
||||
body = "",
|
||||
color = DEFAULT_COLOR,
|
||||
kind = KIND_LIST,
|
||||
items = content.lines().map { it.trim() }.filter { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
private fun draft(content: String): NoteDraft =
|
||||
// The core names the note from the body's first line, so a captured thought is
|
||||
// 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)
|
||||
|
||||
@@ -9,7 +9,6 @@ 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.FilterChip
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -58,28 +57,26 @@ import com.fabledsword.thoughtsync.R
|
||||
fun ComposeSheet(
|
||||
saving: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (DraftKind, String, String) -> 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 kind by rememberSaveable { mutableStateOf(DraftKind.NOTE) }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var content by rememberSaveable { mutableStateOf("") }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
|
||||
val written = title.isNotBlank() || content.isNotBlank()
|
||||
val leave = { if (written) onSave(kind, title, content) else onDismiss() }
|
||||
val written = content.isNotBlank()
|
||||
val leave = { if (written) onSave(content) else onDismiss() }
|
||||
|
||||
// Land in the body, not the title. Most captures are a thought, not a titled
|
||||
// document, and making someone tab past an optional field is the difference
|
||||
// between "under a second" and not.
|
||||
// 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(kind, title, content) }
|
||||
FlushOnStop { if (written) onSave(content) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
|
||||
Column(
|
||||
@@ -91,43 +88,20 @@ fun ComposeSheet(
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = kind == DraftKind.NOTE,
|
||||
onClick = { kind = DraftKind.NOTE },
|
||||
label = { Text(stringResource(R.string.compose_kind_note)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = kind == DraftKind.LIST,
|
||||
onClick = { kind = DraftKind.LIST },
|
||||
label = { Text(stringResource(R.string.compose_kind_list)) },
|
||||
)
|
||||
}
|
||||
|
||||
PlainTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
hint = R.string.compose_title_hint,
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
// 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 =
|
||||
if (kind == DraftKind.LIST) {
|
||||
R.string.compose_list_hint
|
||||
} else {
|
||||
R.string.compose_body_hint
|
||||
},
|
||||
hint = R.string.compose_body_hint,
|
||||
minLines = MIN_CONTENT_LINES,
|
||||
)
|
||||
|
||||
SheetActions(
|
||||
canSave = !saving && written,
|
||||
onDiscard = onDismiss,
|
||||
onSave = { onSave(kind, title, content) },
|
||||
onSave = { onSave(content) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ sealed interface EditorAction {
|
||||
data object DismissError : EditorAction
|
||||
|
||||
data class SaveText(
|
||||
val title: String,
|
||||
val body: String,
|
||||
) : EditorAction
|
||||
|
||||
@@ -31,13 +30,13 @@ sealed interface EditorAction {
|
||||
) : EditorAction
|
||||
|
||||
/**
|
||||
* Note ⇄ checklist.
|
||||
* Give this note a checklist.
|
||||
*
|
||||
* Only `kind` changes: the body text and any existing items both stay where
|
||||
* they are, so switching back and forth is lossless and a mis-tap costs
|
||||
* nothing.
|
||||
* 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 ToggleKind : EditorAction
|
||||
data object AddChecklist : EditorAction
|
||||
|
||||
data class SetPinned(
|
||||
val pinned: Boolean,
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
@@ -84,17 +83,18 @@ fun EditorBottomBar(
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onAction(EditorAction.ToggleKind) }) {
|
||||
val list = note.kind == KIND_LIST
|
||||
// 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) }) {
|
||||
Icon(
|
||||
if (list) Icons.Filled.Create else Icons.AutoMirrored.Filled.List,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (list) R.string.editor_make_note else R.string.editor_make_list,
|
||||
),
|
||||
Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f))
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -50,23 +49,10 @@ fun NoteCard(
|
||||
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// A title only renders when one was actually set. `displayTitle` is
|
||||
// derived from the first body line when it wasn't, so printing both would
|
||||
// show the same text twice.
|
||||
note.title?.takeIf { it.isNotBlank() }?.let { title ->
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
if (note.kind == KIND_LIST) {
|
||||
Checklist(items = note.items)
|
||||
} else if (note.body.isNotBlank()) {
|
||||
// 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,
|
||||
@@ -74,10 +60,14 @@ fun NoteCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (note.items.isNotEmpty()) {
|
||||
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
|
||||
Checklist(items = note.items)
|
||||
}
|
||||
|
||||
// A note with no title, no body and no items still has to occupy the
|
||||
// board legibly — otherwise it reads as a rendering bug.
|
||||
if (note.title.isNullOrBlank() && note.body.isBlank() && note.items.isEmpty()) {
|
||||
// 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()) {
|
||||
Text(
|
||||
text = stringResource(R.string.board_empty_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
@@ -62,7 +61,6 @@ fun NoteEditorScreen(
|
||||
|
||||
// Keyed by note id: the editor is reused across notes, and without the key the
|
||||
// second note opened would show the first one's text.
|
||||
var title by remember(note.id) { mutableStateOf(note.title.orEmpty()) }
|
||||
var body by remember(note.id) { mutableStateOf(note.body) }
|
||||
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
||||
@@ -77,8 +75,8 @@ fun NoteEditorScreen(
|
||||
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
||||
// revision identical to the one before it.
|
||||
val flush = {
|
||||
if (!readOnly && (title != note.title.orEmpty() || body != note.body)) {
|
||||
onAction(EditorAction.SaveText(title, body))
|
||||
if (!readOnly && body != note.body) {
|
||||
onAction(EditorAction.SaveText(body))
|
||||
}
|
||||
}
|
||||
val leave = {
|
||||
@@ -142,17 +140,9 @@ fun NoteEditorScreen(
|
||||
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
|
||||
}
|
||||
|
||||
EditorField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
hint = R.string.editor_title_hint,
|
||||
enabled = !readOnly,
|
||||
bold = true,
|
||||
)
|
||||
|
||||
if (note.kind == KIND_LIST) {
|
||||
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
|
||||
} else {
|
||||
// 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 },
|
||||
@@ -160,6 +150,11 @@ fun NoteEditorScreen(
|
||||
enabled = !readOnly,
|
||||
minLines = MIN_BODY_LINES,
|
||||
)
|
||||
|
||||
// Below the body, not instead of it, and only once the note has items —
|
||||
// the toolbar's add-checklist action is what puts the first one there.
|
||||
if (note.items.isNotEmpty()) {
|
||||
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
|
||||
}
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
@@ -250,11 +245,15 @@ private fun EditorOverlays(
|
||||
}
|
||||
|
||||
/**
|
||||
* The title and body fields.
|
||||
* The note's body field.
|
||||
*
|
||||
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun EditorField(
|
||||
@@ -262,7 +261,6 @@ private fun EditorField(
|
||||
onValueChange: (String) -> Unit,
|
||||
@StringRes hint: Int,
|
||||
enabled: Boolean,
|
||||
bold: Boolean = false,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
PlainTextField(
|
||||
@@ -270,16 +268,8 @@ private fun EditorField(
|
||||
onValueChange = onValueChange,
|
||||
hint = hint,
|
||||
enabled = enabled,
|
||||
// The title is one line by contract — it is a name, and a name that wraps
|
||||
// has become a body. The body itself never is.
|
||||
singleLine = bold,
|
||||
minLines = minLines,
|
||||
textStyle =
|
||||
if (bold) {
|
||||
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
} else {
|
||||
MaterialTheme.typography.bodyLarge
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
/**
|
||||
* The core's `kind` vocabulary, which the UI has to match exactly.
|
||||
*
|
||||
* Shared rather than repeated because it was already living in three places — the
|
||||
* card deciding whether to draw checkboxes, the editor deciding which field to
|
||||
* show, and the view model deciding what to create — and a typo in any one of them
|
||||
* would silently render a checklist as a paragraph rather than fail.
|
||||
*
|
||||
* Strings and not an enum: this is a value the STORE owns, arriving from a server
|
||||
* that may be newer than this client, and an unrecognised kind has to fall through
|
||||
* to "render it as a note" rather than throw.
|
||||
*/
|
||||
internal const val KIND_TEXT = "text"
|
||||
internal const val KIND_LIST = "list"
|
||||
@@ -10,11 +10,7 @@
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
<string name="compose_kind_note">Note</string>
|
||||
<string name="compose_kind_list">List</string>
|
||||
<string name="compose_title_hint">Title</string>
|
||||
<string name="compose_body_hint">Take a note…</string>
|
||||
<string name="compose_list_hint">One item per line</string>
|
||||
<string name="compose_discard">Discard</string>
|
||||
<string name="compose_save">Save</string>
|
||||
|
||||
@@ -41,14 +37,12 @@
|
||||
<!-- Editor -->
|
||||
<string name="board_open_note">Open note</string>
|
||||
<string name="editor_back">Back to notes</string>
|
||||
<string name="editor_title_hint">Title</string>
|
||||
<string name="editor_add_checklist">Add a checklist</string>
|
||||
<string name="editor_body_hint">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_make_list">Make a checklist</string>
|
||||
<string name="editor_make_note">Switch to a note</string>
|
||||
<string name="editor_more">More actions</string>
|
||||
<string name="editor_pin">Pin</string>
|
||||
<string name="editor_unpin">Unpin</string>
|
||||
|
||||
+25
-45
@@ -568,12 +568,10 @@ mod tests {
|
||||
dir.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn draft(title: &str, body: &str) -> NoteDraft {
|
||||
fn draft(body: &str) -> NoteDraft {
|
||||
NoteDraft {
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
color: "default".to_string(),
|
||||
kind: None,
|
||||
items: None,
|
||||
}
|
||||
}
|
||||
@@ -588,62 +586,50 @@ mod tests {
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("Groceries", "milk"))
|
||||
.create_note(draft("Groceries\nmilk"))
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.title.as_deref(), Some("Groceries"));
|
||||
assert_eq!(created.body, "milk");
|
||||
assert_eq!(created.body, "Groceries\nmilk");
|
||||
|
||||
let fetched = app
|
||||
.get_note(created.id.clone())
|
||||
.expect("get should succeed");
|
||||
assert_eq!(fetched.id, created.id);
|
||||
// The NAME is the first line — there is no title field to have set (M13 step 3).
|
||||
assert_eq!(fetched.display_title, "Groceries");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A body-only note still has to be nameable — that is what `display_title` is
|
||||
/// for, and the Android board relies on it exactly as the desktop does.
|
||||
/// Every note has to be nameable — that is what `display_title` is for, and the
|
||||
/// Android board relies on it exactly as the desktop does.
|
||||
#[test]
|
||||
fn body_only_notes_still_have_a_display_title() {
|
||||
fn a_note_is_named_by_its_first_line() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let created = app
|
||||
.create_note(draft("", "just a thought"))
|
||||
.create_note(draft("just a thought"))
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.title, None);
|
||||
assert_eq!(created.display_title, "just a thought");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clearing a field and setting one are different edits, and the difference has
|
||||
/// to survive the trip through the patch object.
|
||||
/// The hole that made removing the title unsafe until checklists stopped being
|
||||
/// their own kind of thing: a note with no body text still needs a name.
|
||||
#[test]
|
||||
fn edits_can_both_set_and_clear_a_title() {
|
||||
fn a_note_with_only_items_is_named_by_its_first_item() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("First", "body")).expect("create");
|
||||
|
||||
let renamed = app
|
||||
.update_note(
|
||||
note.id.clone(),
|
||||
vec![NoteEdit::Title {
|
||||
value: "Second".to_string(),
|
||||
}],
|
||||
)
|
||||
.expect("rename");
|
||||
assert_eq!(renamed.title.as_deref(), Some("Second"));
|
||||
|
||||
let cleared = app
|
||||
.update_note(note.id.clone(), vec![NoteEdit::ClearTitle])
|
||||
.expect("clear");
|
||||
assert_eq!(
|
||||
cleared.title, None,
|
||||
"ClearTitle must null the column, not set it to an empty string — the \
|
||||
distinction is why NoteEdit is a list rather than a struct of options"
|
||||
);
|
||||
let created = app
|
||||
.create_note(NoteDraft {
|
||||
body: String::new(),
|
||||
color: "default".to_string(),
|
||||
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
||||
})
|
||||
.expect("create should succeed");
|
||||
assert_eq!(created.display_title, "milk");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -674,10 +660,8 @@ mod tests {
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app
|
||||
.create_note(NoteDraft {
|
||||
title: "Packing".to_string(),
|
||||
body: String::new(),
|
||||
body: "Packing".to_string(),
|
||||
color: "default".to_string(),
|
||||
kind: Some("list".to_string()),
|
||||
items: Some(vec!["socks".to_string()]),
|
||||
})
|
||||
.expect("create");
|
||||
@@ -730,7 +714,7 @@ mod tests {
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
|
||||
let note = app
|
||||
.create_note(draft("Trip", "book the ferry #travel"))
|
||||
.create_note(draft("Trip\nbook the ferry #travel"))
|
||||
.expect("create");
|
||||
assert_eq!(
|
||||
note.labels.len(),
|
||||
@@ -769,7 +753,7 @@ mod tests {
|
||||
fn deleting_forever_removes_the_note() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Ephemeral", "body")).expect("create");
|
||||
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
||||
|
||||
app.delete_note_forever(note.id.clone())
|
||||
.expect("delete forever");
|
||||
@@ -786,7 +770,7 @@ mod tests {
|
||||
fn reminders_can_be_snoozed_and_completed() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app.create_note(draft("Call back", "")).expect("create");
|
||||
let note = app.create_note(draft("Call back")).expect("create");
|
||||
assert_eq!(note.remind_at, None);
|
||||
|
||||
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
|
||||
@@ -812,9 +796,7 @@ mod tests {
|
||||
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
||||
let dir = scratch_dir();
|
||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app
|
||||
.create_note(draft("Water the plants", ""))
|
||||
.expect("create");
|
||||
let note = app.create_note(draft("Water the plants")).expect("create");
|
||||
|
||||
let armed = app
|
||||
.update_note(
|
||||
@@ -851,9 +833,7 @@ mod tests {
|
||||
|
||||
// A one-off clears BOTH fields, so an unrecognised rule cannot linger
|
||||
// invisibly on a note with no reminder.
|
||||
let once = app
|
||||
.create_note(draft("Post the letter", ""))
|
||||
.expect("create");
|
||||
let once = app.create_note(draft("Post the letter")).expect("create");
|
||||
app.update_note(
|
||||
once.id.clone(),
|
||||
vec![NoteEdit::RemindAt {
|
||||
|
||||
+21
-49
@@ -29,13 +29,11 @@ use thoughtsync_core::sync::state as core_state;
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
/// Title if set, else the first body line — always present, so a body-only note
|
||||
/// is still nameable. Derived by the core, never stored.
|
||||
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||
/// Always present. Derived by the core, never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
pub kind: String,
|
||||
pub position: i64,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
@@ -130,11 +128,9 @@ impl From<core_models::Note> for Note {
|
||||
// Exhaustive on purpose — see the module header.
|
||||
let core_models::Note {
|
||||
id,
|
||||
title,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
@@ -151,11 +147,9 @@ impl From<core_models::Note> for Note {
|
||||
} = value;
|
||||
Note {
|
||||
id,
|
||||
title,
|
||||
display_title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
position,
|
||||
pinned,
|
||||
archived,
|
||||
@@ -294,7 +288,6 @@ pub struct NoteQuery {
|
||||
pub struct NoteFacets {
|
||||
pub q: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub label: Option<Vec<String>>,
|
||||
pub has_reminder: Option<bool>,
|
||||
pub has_attachment: Option<bool>,
|
||||
@@ -324,7 +317,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
let NoteFacets {
|
||||
q,
|
||||
color,
|
||||
kind,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
@@ -334,7 +326,6 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
core_models::Facets {
|
||||
q,
|
||||
color,
|
||||
kind,
|
||||
label,
|
||||
has_reminder,
|
||||
has_attachment,
|
||||
@@ -347,31 +338,18 @@ impl From<NoteFacets> for core_models::Facets {
|
||||
/// A new note.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct NoteDraft {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// "default" unless the user picked a colour.
|
||||
pub color: String,
|
||||
pub kind: Option<String>,
|
||||
/// Checklist lines, for `kind = "checklist"`.
|
||||
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
|
||||
/// is not an alternative to `body` — it is an addition to it.
|
||||
pub items: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||
fn from(value: NoteDraft) -> Self {
|
||||
let NoteDraft {
|
||||
title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
items,
|
||||
} = value;
|
||||
core_models::NoteCreateInput {
|
||||
title,
|
||||
body,
|
||||
color,
|
||||
kind,
|
||||
items,
|
||||
}
|
||||
let NoteDraft { body, color, items } = value;
|
||||
core_models::NoteCreateInput { body, color, items }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,17 +357,14 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||
///
|
||||
/// A LIST of these rather than a struct of optional fields, because the core's patch
|
||||
/// semantics distinguish three states — leave alone, set to a value, and clear to
|
||||
/// null — and Kotlin has no way to express the third with a nullable field. `title:
|
||||
/// null` in a data class is indistinguishable from `title` unset, so the editor
|
||||
/// could never clear a title. Explicit `Clear*` variants say it out loud, and Kotlin
|
||||
/// gets a sealed class it can `when` over exhaustively.
|
||||
/// null — and Kotlin has no way to express the third with a nullable field.
|
||||
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
|
||||
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
|
||||
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
|
||||
#[derive(Debug, Clone, uniffi::Enum)]
|
||||
pub enum NoteEdit {
|
||||
Title { value: String },
|
||||
ClearTitle,
|
||||
Body { value: String },
|
||||
Color { value: String },
|
||||
Kind { value: String },
|
||||
Pinned { value: bool },
|
||||
Archived { value: bool },
|
||||
RemindAt { value: String },
|
||||
@@ -408,11 +383,8 @@ impl NoteEdit {
|
||||
fn entry(self) -> (&'static str, serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
match self {
|
||||
NoteEdit::Title { value } => ("title", Value::String(value)),
|
||||
NoteEdit::ClearTitle => ("title", Value::Null),
|
||||
NoteEdit::Body { value } => ("body", Value::String(value)),
|
||||
NoteEdit::Color { value } => ("color", Value::String(value)),
|
||||
NoteEdit::Kind { value } => ("kind", Value::String(value)),
|
||||
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
||||
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
||||
@@ -425,8 +397,8 @@ impl NoteEdit {
|
||||
|
||||
/// Fold a list of edits into the single patch object the store applies.
|
||||
///
|
||||
/// Later edits win on a repeated key, which is what a caller batching "set title,
|
||||
/// then clear title" would expect.
|
||||
/// Later edits win on a repeated key, which is what a caller batching "set a
|
||||
/// reminder, then clear it" would expect.
|
||||
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
|
||||
let mut map = serde_json::Map::new();
|
||||
for edit in edits {
|
||||
@@ -707,14 +679,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_set_and_a_clear_are_different_patch_entries() {
|
||||
let set = patch_from(vec![NoteEdit::Title {
|
||||
value: "x".to_string(),
|
||||
let set = patch_from(vec![NoteEdit::RemindAt {
|
||||
value: "2026-01-01T00:00:00Z".to_string(),
|
||||
}]);
|
||||
assert_eq!(set["title"], serde_json::json!("x"));
|
||||
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
|
||||
|
||||
let cleared = patch_from(vec![NoteEdit::ClearTitle]);
|
||||
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
|
||||
assert!(
|
||||
cleared["title"].is_null(),
|
||||
cleared["remind_at"].is_null(),
|
||||
"a clear must reach the store as JSON null — an absent key means \
|
||||
'leave alone', which is a different instruction"
|
||||
);
|
||||
@@ -730,11 +702,11 @@ mod tests {
|
||||
#[test]
|
||||
fn later_edits_win_on_a_repeated_field() {
|
||||
let patch = patch_from(vec![
|
||||
NoteEdit::Title {
|
||||
value: "first".to_string(),
|
||||
NoteEdit::RemindAt {
|
||||
value: "2026-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
NoteEdit::ClearTitle,
|
||||
NoteEdit::ClearRemindAt,
|
||||
]);
|
||||
assert!(patch["title"].is_null());
|
||||
assert!(patch["remind_at"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,43 @@ entirely on `ci-python:3.14`.
|
||||
`…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use
|
||||
`python3 -m zipfile -e`.
|
||||
|
||||
## The integration lane
|
||||
|
||||
Added 2026-08-23. Before it, `alembic upgrade head` ran for the first time when the
|
||||
operator's container started — 26 revisions, none of them ever executed by CI — and
|
||||
the schema the migrations build had never been checked against the models that read
|
||||
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
|
||||
watching.
|
||||
|
||||
Copied from FabledScribe's `integration` job, which had already solved the awkward
|
||||
parts. Three of them are family rules for a reason:
|
||||
|
||||
- **Job key `integration`, no `name:`** (rule 80). act_runner derives the service
|
||||
container's name from the truncated job DISPLAY name, and the discovery step filters
|
||||
`docker ps` by it. A spaced or underscored name breaks the filter.
|
||||
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
|
||||
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
|
||||
builds `THOUGHTSYNC_DATABASE_URL` from it. `postgres:5432` will not connect.
|
||||
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
|
||||
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
|
||||
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
|
||||
|
||||
`postgres:16-alpine`, matching the production compose, so the schema is proven against
|
||||
the Postgres it will actually meet. The schema comes from **real migrations, never
|
||||
`metadata.create_all`** (rule 82): testing a schema no deployment has ever seen proves
|
||||
nothing, and that `alembic upgrade head` step IS the migration test — a broken revision
|
||||
fails the job there, before it can fail a container start.
|
||||
|
||||
Tests are marked `integration` (registered in `pyproject.toml`); the unit lane runs
|
||||
`-m "not integration"` and stays DB-free. Data resets with `TRUNCATE ... CASCADE`
|
||||
BEFORE each test rather than after, so a failure leaves its rows behind to look at.
|
||||
|
||||
Like `test`, it runs for visibility and does **not** gate the build.
|
||||
|
||||
There is no local way to run it — that would mean standing up Postgres on the
|
||||
workstation, which rule 12 reserves for an explicit request. This lane is verified in
|
||||
CI.
|
||||
|
||||
## Desktop (Tauri) lane — separate workflow
|
||||
|
||||
The Tauri desktop client (`desktop/`) builds in its own workflow,
|
||||
@@ -305,11 +342,37 @@ matched CI run 3931's byte for byte. Same image, same lockfile, same units.
|
||||
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
|
||||
whenever the space is wanted.
|
||||
|
||||
**Run these on every Rust-touching push, not just the ones that feel risky.** Four
|
||||
consecutive failures across M13's removals — a private `fn` deleted along with the
|
||||
`pub fn` above it, an orphaned `#[serde]` attribute left where a field was removed,
|
||||
and a test pinning a protocol version literal — were all caught by these three
|
||||
commands in under a minute each, after CI had already found them the slow way. A
|
||||
removal is exactly the kind of change that looks safe and isn't: nothing in the
|
||||
Python or TypeScript lanes compiles Rust, so a break can travel several commits
|
||||
before the first lane that does gets to it.
|
||||
|
||||
**Don't infer formatting from existing code.** Several lines in `local/store.rs`
|
||||
exceed 100 characters and survive only because rustfmt cannot break a string
|
||||
literal — copying that shape caused one of four consecutive fmt-only CI failures,
|
||||
which is what this whole section exists to prevent.
|
||||
|
||||
## Checking the frontend lane before pushing
|
||||
|
||||
Same technique, same authorisation, same reason — and it covers a gap the Rust gate
|
||||
cannot: `vue-tsc --noEmit` type-checks only the SCRIPT block, so a malformed TEMPLATE
|
||||
passes the typecheck lane and fails `vite build` in a different workflow. `npm run
|
||||
build` runs both, which is exactly what the desktop lanes run.
|
||||
|
||||
```
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/w" -w /w/frontend \
|
||||
git.fabledsword.com/bvandeusen/ci-python:3.14 sh -c "npm ci --silent && npm run build"
|
||||
```
|
||||
|
||||
The typecheck lane uses the `ci-python` image too — it is the node the frontend jobs
|
||||
already run on, not a separate one. Delete `frontend/node_modules` and `frontend/dist`
|
||||
afterwards; both are gitignored, but neither belongs in a working tree that never
|
||||
builds locally otherwise.
|
||||
|
||||
## The desktop lockfile
|
||||
|
||||
`Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for
|
||||
|
||||
@@ -8,13 +8,12 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Serialize)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
/// title if set, else the note's first body line — always present, so body-only
|
||||
/// notes still have something to be called. Derived, never stored.
|
||||
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||
/// Always present, so every note has something to be called. Derived at read time,
|
||||
/// never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
pub kind: String,
|
||||
pub position: i64,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
@@ -72,7 +71,6 @@ pub struct LinkPreview {
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteRevision {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub body: String,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
@@ -129,15 +127,11 @@ fn default_color() -> String {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NoteCreateInput {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub items: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -162,8 +156,6 @@ pub struct Facets {
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub has_reminder: Option<bool>,
|
||||
|
||||
@@ -93,8 +93,8 @@ mod tests {
|
||||
let when = Utc::now() - age;
|
||||
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)",
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES (?1, 'B', ?2, ?2, 1, ?2)",
|
||||
rusqlite::params![id, stamped],
|
||||
)
|
||||
.expect("insert");
|
||||
@@ -149,8 +149,8 @@ mod tests {
|
||||
fn an_untrashed_note_is_never_swept() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed)
|
||||
VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, trashed)
|
||||
VALUES ('live', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
|
||||
[],
|
||||
)
|
||||
.expect("insert");
|
||||
@@ -163,8 +163,8 @@ mod tests {
|
||||
// "Age unknown" must never resolve to "delete it".
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('weird', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('weird', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
|
||||
[],
|
||||
)
|
||||
.expect("insert");
|
||||
@@ -179,8 +179,8 @@ mod tests {
|
||||
let conn = db();
|
||||
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)",
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('server', 'B', ?1, ?1, 1, ?1)",
|
||||
rusqlite::params![stamped],
|
||||
)
|
||||
.expect("insert");
|
||||
|
||||
@@ -14,7 +14,7 @@ CREATE TABLE notes (
|
||||
title TEXT,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
kind TEXT NOT NULL DEFAULT 'text', -- 'text' | 'list'
|
||||
kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -160,6 +160,26 @@ CREATE TABLE prefs (
|
||||
);
|
||||
"#;
|
||||
|
||||
// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something
|
||||
// a note IS — the column was a mode flag with no enum and no constraint behind it,
|
||||
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was
|
||||
// 'list' keeps every one of its items.
|
||||
//
|
||||
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
|
||||
const SCHEMA_V6: &str = r#"
|
||||
ALTER TABLE notes DROP COLUMN kind;
|
||||
"#;
|
||||
|
||||
// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and
|
||||
// its NAME is the first non-empty line of that body, falling back to its first item —
|
||||
// derived at read time, never stored (see store::display_title).
|
||||
//
|
||||
// note_revisions loses its copy for the same reason: a revision snapshots a body.
|
||||
const SCHEMA_V7: &str = r#"
|
||||
ALTER TABLE notes DROP COLUMN title;
|
||||
ALTER TABLE note_revisions DROP COLUMN title;
|
||||
"#;
|
||||
|
||||
/// Bring the database up to the latest schema. Idempotent.
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
@@ -184,5 +204,13 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(SCHEMA_V5)?;
|
||||
conn.execute_batch("PRAGMA user_version = 5;")?;
|
||||
}
|
||||
if version < 6 {
|
||||
conn.execute_batch(SCHEMA_V6)?;
|
||||
conn.execute_batch("PRAGMA user_version = 6;")?;
|
||||
}
|
||||
if version < 7 {
|
||||
conn.execute_batch(SCHEMA_V7)?;
|
||||
conn.execute_batch("PRAGMA user_version = 7;")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+72
-78
@@ -24,30 +24,26 @@ fn new_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// title if non-empty, else the first non-blank body line — always a string.
|
||||
fn display_title(title: Option<&str>, body: &str) -> String {
|
||||
if let Some(t) = title {
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
return t.to_string();
|
||||
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||
///
|
||||
/// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
body.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
items
|
||||
.iter()
|
||||
.map(|i| i.text.trim())
|
||||
.find(|t| !t.is_empty())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_title(raw: &str) -> Option<String> {
|
||||
let t = raw.trim();
|
||||
if t.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(t.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_like(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
@@ -139,33 +135,29 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
|
||||
|
||||
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
let mut note = conn.query_row(
|
||||
"SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
|
||||
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
|
||||
FROM notes WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
let title: Option<String> = r.get(1)?;
|
||||
let body: String = r.get(2)?;
|
||||
let dt = display_title(title.as_deref(), &body);
|
||||
let body: String = r.get(1)?;
|
||||
Ok(Note {
|
||||
id: r.get(0)?,
|
||||
title,
|
||||
display_title: dt,
|
||||
display_title: String::new(), // filled below — it may need a query
|
||||
body,
|
||||
color: r.get(3)?,
|
||||
kind: r.get(4)?,
|
||||
position: r.get(5)?,
|
||||
pinned: r.get(6)?,
|
||||
archived: r.get(7)?,
|
||||
trashed: r.get(8)?,
|
||||
deleted_at: r.get(13)?,
|
||||
remind_at: r.get(9)?,
|
||||
recurrence: r.get(10)?,
|
||||
color: r.get(2)?,
|
||||
position: r.get(3)?,
|
||||
pinned: r.get(4)?,
|
||||
archived: r.get(5)?,
|
||||
trashed: r.get(6)?,
|
||||
deleted_at: r.get(11)?,
|
||||
remind_at: r.get(7)?,
|
||||
recurrence: r.get(8)?,
|
||||
labels: Vec::new(),
|
||||
items: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
previews: Vec::new(),
|
||||
created_at: r.get(11)?,
|
||||
updated_at: r.get(12)?,
|
||||
created_at: r.get(9)?,
|
||||
updated_at: r.get(10)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
@@ -173,6 +165,8 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
note.items = load_items(conn, id)?;
|
||||
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);
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
@@ -268,7 +262,7 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
||||
|
||||
if let Some(f) = &q.facets {
|
||||
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
|
||||
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
||||
let pat = format!("%{}%", escape_like(text));
|
||||
binds.push(pat.clone());
|
||||
binds.push(pat);
|
||||
@@ -277,10 +271,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
|
||||
sql.push_str(" AND color = ?");
|
||||
binds.push(c.to_string());
|
||||
}
|
||||
if let Some(k) = f.kind.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND kind = ?");
|
||||
binds.push(k.to_string());
|
||||
}
|
||||
if f.has_reminder == Some(true) {
|
||||
sql.push_str(" AND remind_at IS NOT NULL");
|
||||
}
|
||||
@@ -326,23 +316,31 @@ pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
|
||||
}
|
||||
|
||||
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
|
||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let title: Option<String> = r.get(1)?;
|
||||
let body: String = r.get(2)?;
|
||||
// Names come from `load_note` rather than from a bare row, because a note whose
|
||||
// body is empty is named by its first checklist item — which a row here doesn't
|
||||
// have. The command palette reads this; correctness beats one query per note at
|
||||
// personal scale.
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let note = load_note(conn, id)?;
|
||||
Ok(TitleEntry {
|
||||
id: r.get(0)?,
|
||||
title: display_title(title.as_deref(), &body),
|
||||
id: note.id,
|
||||
title: note.display_title,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
||||
let pat = format!("%{}%", escape_like(q));
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id FROM notes WHERE trashed = 0 AND (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC",
|
||||
"SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
@@ -355,17 +353,15 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
||||
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
|
||||
let id = new_id();
|
||||
let ts = now();
|
||||
let title = normalize_title(&input.title);
|
||||
let kind = input.kind.clone().unwrap_or_else(|| "text".to_string());
|
||||
let position: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)",
|
||||
params![id, title, input.body, input.color, kind, position, ts],
|
||||
"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],
|
||||
)?;
|
||||
if let Some(items) = &input.items {
|
||||
for (i, text) in items.iter().enumerate() {
|
||||
@@ -379,25 +375,29 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
|
||||
load_note(conn, &id)
|
||||
}
|
||||
|
||||
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))?;
|
||||
conn.execute(
|
||||
"INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_id(), id, body, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PATCH semantics: apply exactly the fields present in `changes`.
|
||||
pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Result<Note> {
|
||||
let obj = changes
|
||||
.as_object()
|
||||
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
||||
|
||||
// Snapshot the pre-edit title/body once if either is being changed (version history).
|
||||
if obj.contains_key("title") || obj.contains_key("body") {
|
||||
// Snapshot the pre-edit body before changing it (version history).
|
||||
if obj.contains_key("body") {
|
||||
snapshot_revision(conn, id)?;
|
||||
}
|
||||
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"title" => {
|
||||
let norm = v.as_str().and_then(normalize_title);
|
||||
conn.execute(
|
||||
"UPDATE notes SET title = ?1 WHERE id = ?2",
|
||||
params![norm, id],
|
||||
)?;
|
||||
}
|
||||
"body" => {
|
||||
let body = v.as_str().unwrap_or("");
|
||||
conn.execute(
|
||||
@@ -411,11 +411,6 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
|
||||
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
}
|
||||
"kind" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
conn.execute("UPDATE notes SET kind = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
}
|
||||
"pinned" => {
|
||||
if let Some(b) = v.as_bool() {
|
||||
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
|
||||
@@ -651,28 +646,27 @@ pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<(
|
||||
|
||||
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
|
||||
.prepare("SELECT id, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
|
||||
let rows = stmt.query_map([id], |r| {
|
||||
Ok(NoteRevision {
|
||||
id: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
body: r.get(2)?,
|
||||
created_at: r.get(3)?,
|
||||
body: r.get(1)?,
|
||||
created_at: r.get(2)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
|
||||
let (title, body): (Option<String>, String) = conn.query_row(
|
||||
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
|
||||
let body: String = conn.query_row(
|
||||
"SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
|
||||
params![rev_id, id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
snapshot_revision(conn, id)?;
|
||||
conn.execute(
|
||||
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
|
||||
params![title, body, id],
|
||||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||
params![body, id],
|
||||
)?;
|
||||
sync_tags(conn, id, &body)?;
|
||||
touch(conn, id)?;
|
||||
|
||||
+12
-8
@@ -19,11 +19,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The sync wire protocol this client speaks.
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 1;
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
/// 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 = 1;
|
||||
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
|
||||
/// link rather than degrading it.
|
||||
@@ -344,13 +344,17 @@ mod tests {
|
||||
fn server_info_tolerates_unknown_and_absent_fields() {
|
||||
// Forward compatibility: a NEWER server sending fields we've never heard of
|
||||
// must not break the handshake.
|
||||
let info: ServerInfo = serde_json::from_str(
|
||||
r#"{"site_name":"S","sync_protocol_version":1,
|
||||
"min_client_protocol_version":1,
|
||||
// Versions come from the constants, not literals: this test is about unknown
|
||||
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
|
||||
// to v2 — for a reason that has nothing to do with what it checks.
|
||||
let body = format!(
|
||||
r#"{{"site_name":"S","sync_protocol_version":{v},
|
||||
"min_client_protocol_version":{v},
|
||||
"sync_features":["notes","labels","attachments","tombstones","revisions"],
|
||||
"some_future_field":{"nested":true}}"#,
|
||||
)
|
||||
.expect("unknown fields are ignored");
|
||||
"some_future_field":{{"nested":true}}}}"#,
|
||||
v = CLIENT_PROTOCOL_VERSION,
|
||||
);
|
||||
let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored");
|
||||
assert_eq!(evaluate(&info), Compatibility::Ok);
|
||||
}
|
||||
|
||||
|
||||
@@ -240,15 +240,13 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
|
||||
// never changes, and the server's copy is the same value anyway.
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
|
||||
"INSERT INTO notes (id, body, color, position, pinned, archived,
|
||||
trashed, remind_at, recurrence, created_at, updated_at,
|
||||
sync_revision, trashed_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 0)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
body = excluded.body,
|
||||
color = excluded.color,
|
||||
kind = excluded.kind,
|
||||
position = excluded.position,
|
||||
pinned = excluded.pinned,
|
||||
archived = excluded.archived,
|
||||
@@ -261,10 +259,8 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
dirty = 0",
|
||||
params![
|
||||
note.id,
|
||||
note.title,
|
||||
note.body,
|
||||
note.color,
|
||||
note.kind,
|
||||
note.position,
|
||||
note.pinned,
|
||||
note.archived,
|
||||
@@ -498,10 +494,8 @@ mod tests {
|
||||
fn note(id: &str, revision: i64) -> wire::Note {
|
||||
wire::Note {
|
||||
id: id.to_string(),
|
||||
title: Some("Title".into()),
|
||||
body: "Body".into(),
|
||||
color: "default".into(),
|
||||
kind: "text".into(),
|
||||
position: 0,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
|
||||
+13
-27
@@ -62,15 +62,11 @@ pub struct Change {
|
||||
pub op: &'static str,
|
||||
pub edited_at: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pinned: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub archived: Option<bool>,
|
||||
@@ -99,10 +95,8 @@ impl Change {
|
||||
id,
|
||||
op: "delete",
|
||||
edited_at,
|
||||
title: None,
|
||||
body: None,
|
||||
color: None,
|
||||
kind: None,
|
||||
pinned: None,
|
||||
archived: None,
|
||||
trashed: None,
|
||||
@@ -200,9 +194,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
|
||||
name: Some(r.get(1)?),
|
||||
color: Some(r.get(2)?),
|
||||
edited_at: r.get(3)?,
|
||||
title: None,
|
||||
body: None,
|
||||
kind: None,
|
||||
pinned: None,
|
||||
archived: None,
|
||||
trashed: None,
|
||||
@@ -237,10 +229,8 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
|
||||
/// The note's own columns. A named struct rather than a twelve-wide tuple so the
|
||||
/// field-to-column mapping stays readable at the call site.
|
||||
struct NoteRow {
|
||||
title: Option<String>,
|
||||
body: String,
|
||||
color: String,
|
||||
kind: String,
|
||||
position: i64,
|
||||
pinned: bool,
|
||||
archived: bool,
|
||||
@@ -253,24 +243,22 @@ struct NoteRow {
|
||||
|
||||
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||
conn.query_row(
|
||||
"SELECT title, body, color, kind, position, pinned, archived, trashed,
|
||||
"SELECT body, color, position, pinned, archived, trashed,
|
||||
remind_at, recurrence, created_at, updated_at
|
||||
FROM notes WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok(NoteRow {
|
||||
title: r.get(0)?,
|
||||
body: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
kind: r.get(3)?,
|
||||
position: r.get(4)?,
|
||||
pinned: r.get::<_, i64>(5)? != 0,
|
||||
archived: r.get::<_, i64>(6)? != 0,
|
||||
trashed: r.get::<_, i64>(7)? != 0,
|
||||
remind_at: r.get(8)?,
|
||||
recurrence: r.get(9)?,
|
||||
created_at: r.get(10)?,
|
||||
updated_at: r.get(11)?,
|
||||
body: r.get(0)?,
|
||||
color: r.get(1)?,
|
||||
position: r.get(2)?,
|
||||
pinned: r.get::<_, i64>(3)? != 0,
|
||||
archived: r.get::<_, i64>(4)? != 0,
|
||||
trashed: r.get::<_, i64>(5)? != 0,
|
||||
remind_at: r.get(6)?,
|
||||
recurrence: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
updated_at: r.get(9)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -309,10 +297,8 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
// The local `updated_at` IS the client's edit time, which is what the
|
||||
// server's last-write-wins comparison runs against.
|
||||
edited_at: row.updated_at,
|
||||
title: row.title,
|
||||
body: Some(row.body),
|
||||
color: Some(row.color),
|
||||
kind: Some(row.kind),
|
||||
pinned: Some(row.pinned),
|
||||
archived: Some(row.archived),
|
||||
trashed: Some(row.trashed),
|
||||
@@ -535,9 +521,9 @@ mod tests {
|
||||
|
||||
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
|
||||
"INSERT INTO notes (id, body, color, position, pinned, archived,
|
||||
trashed, created_at, updated_at, sync_revision, dirty)
|
||||
VALUES (?1, 'T', 'B', 'default', 'text', 0, 0, 0, 0,
|
||||
VALUES (?1, 'B', 'default', 0, 0, 0, 0,
|
||||
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
|
||||
params![id, dirty],
|
||||
)
|
||||
|
||||
@@ -24,13 +24,9 @@ pub struct ChangesPage {
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default = "default_kind")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub position: i64,
|
||||
#[serde(default)]
|
||||
@@ -157,10 +153,6 @@ fn default_color() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
fn default_kind() -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
|
||||
fn default_mime() -> String {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "thoughtsync-desktop"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "ThoughtSync desktop — local-first Keep-style thought capture"
|
||||
authors = ["bvandeusen"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -190,12 +190,6 @@ pub fn notes_titles(db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
|
||||
store::titles(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn notes_search(q: String, db: State<'_, Db>) -> Result<Vec<Note>, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
store::search(&conn, &q).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -119,7 +119,6 @@ pub fn run() {
|
||||
commands::local::notes_restore_revision,
|
||||
commands::local::notes_reminders,
|
||||
commands::local::notes_titles,
|
||||
commands::local::notes_search,
|
||||
commands::local::labels_list,
|
||||
commands::local::labels_create,
|
||||
commands::local::labels_rename,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ThoughtSync",
|
||||
"mainBinaryName": "thoughtsync",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.fabledsword.thoughtsync",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend/dist",
|
||||
|
||||
+21
-3
@@ -71,10 +71,28 @@ services:
|
||||
# Uploaded attachments. /var/thoughtsync is fixed in the app (Config.DATA_DIR),
|
||||
# not configurable — mount it or lose every image on container recreation.
|
||||
- thoughtsync-data:/var/thoughtsync
|
||||
# WHERE THE APP IS REACHABLE FROM. Three shapes, and the right answer is
|
||||
# different for each — the default serves the first.
|
||||
#
|
||||
# 1. LAN, no proxy (the default). Binds every interface so your phone and your
|
||||
# desktop can reach the server. This is what makes a self-hosted install work
|
||||
# out of the box, and it is why the default is NOT the locked-down value: a
|
||||
# server only reachable from the machine it runs on is not hardened, it is
|
||||
# broken.
|
||||
#
|
||||
# 2. Reverse proxy in Docker, on this network (Traefik discovering the container,
|
||||
# an nginx container, etc). DELETE the `ports:` block below entirely. The proxy
|
||||
# reaches the app over the compose network without any port being published,
|
||||
# and publishing one is a second, unauthenticated way in that bypasses the
|
||||
# proxy — including whatever the proxy is doing about TLS and auth.
|
||||
#
|
||||
# 3. Reverse proxy on the HOST (not in Docker). Set THOUGHTSYNC_BIND=127.0.0.1 in
|
||||
# .env, so the port exists but only the host itself can reach it.
|
||||
#
|
||||
# If you are exposing this to the internet, you want 2 or 3. Leaving it at 1
|
||||
# means the app is reachable directly on port 5000, past everything your proxy
|
||||
# does.
|
||||
ports:
|
||||
# Default binds every interface, which is what lets desktop clients on the LAN
|
||||
# reach it. Behind a reverse proxy, set THOUGHTSYNC_BIND=127.0.0.1 so only the
|
||||
# proxy can talk to it.
|
||||
- "${THOUGHTSYNC_BIND:-0.0.0.0}:${THOUGHTSYNC_PORT:-5000}:5000"
|
||||
healthcheck:
|
||||
# python rather than curl: the runtime image is python:3.12-slim and carries no
|
||||
|
||||
+55
-19
@@ -7,16 +7,21 @@ one guessed password away from someone's whole note history.
|
||||
This is what the app does about that on its own, and the four things it cannot do for
|
||||
you.
|
||||
|
||||
## Do these four things first
|
||||
## Do these five things first
|
||||
|
||||
**1. Close registration.** `allow_registration` defaults to **on**, because the first
|
||||
run of a fresh instance has to be able to create the admin account. It stays on
|
||||
afterwards. Once your own account exists, turn it off in **Settings → Access → Allow
|
||||
new registrations**, or the first stranger to find the hostname can open an account on
|
||||
your server.
|
||||
**1. Check registration is closed.** On a fresh instance this now takes care of
|
||||
itself: the first account created becomes the admin *and* closes registration behind
|
||||
it, so there is no window between "my account exists" and "I remembered to turn it
|
||||
off". A brand-new instance is never locked out of itself, and never left open either.
|
||||
|
||||
The first account created is always the admin, regardless of this setting — so a
|
||||
brand-new instance is never locked out of itself.
|
||||
**Instances that predate this still need one manual flip.** The close fires when the
|
||||
first account is created, so a server whose admin already existed keeps whatever
|
||||
`allow_registration` was set to — which was **on** by default. Check **Settings →
|
||||
Access → Allow new registrations** before exposing an instance you have been running
|
||||
on a LAN.
|
||||
|
||||
To let someone else in, turn it back on, have them register, turn it off. There is no
|
||||
invite system yet, so that is the mechanism.
|
||||
|
||||
**2. Terminate TLS in front of it, and forward the scheme.** The app marks the
|
||||
session cookie `Secure` and sends HSTS only when it can tell the request arrived over
|
||||
@@ -36,15 +41,38 @@ Once a browser has seen HSTS from your hostname it will refuse plain HTTP there
|
||||
year, even if the header stops. That is the point of it, but it is worth knowing
|
||||
before you put a hostname behind TLS temporarily.
|
||||
|
||||
**3. Stop publishing the app port.** The default compose binds `0.0.0.0:5000` so LAN
|
||||
clients can reach it directly. Behind a proxy that is a second, unprotected front
|
||||
door. In `.env`:
|
||||
**3. Tell it how many proxies are in front of it.** **Settings → Security → Trusted
|
||||
proxy hops**, which defaults to `1` — one reverse proxy terminating TLS. Behind a CDN
|
||||
as well (Cloudflare in front of your proxy) set it to `2`. It applies immediately; no
|
||||
restart.
|
||||
|
||||
```
|
||||
THOUGHTSYNC_BIND=127.0.0.1
|
||||
```
|
||||
This decides which entry of `X-Forwarded-For` is believed, and it is a security
|
||||
setting rather than a preference. The header grows left to right as a request
|
||||
traverses, so the rightmost entries are the ones your own infrastructure wrote and
|
||||
anything a caller forged sits to the left of them. Counting in from the right by the
|
||||
number of proxies you actually run means a forged prefix can never be selected. Set it
|
||||
too HIGH and it starts trusting entries no proxy of yours wrote; too low and several
|
||||
callers share one rate-limit bucket, which is merely inconvenient.
|
||||
|
||||
**4. Have a backup that includes the files.** Attachments are files on the
|
||||
**4. Stop reaching the app except through the proxy.** The default compose binds
|
||||
`0.0.0.0:5000` so LAN clients can reach it directly — which is right for a LAN install
|
||||
and wrong the moment there is a proxy in front, because it leaves a second way in that
|
||||
bypasses everything the proxy does.
|
||||
|
||||
Which fix depends on where your proxy runs:
|
||||
|
||||
- **Proxy in Docker** (Traefik discovering the container, an nginx container): delete
|
||||
the `ports:` block from `docker-compose.yml`. The proxy reaches the app over the
|
||||
compose network; no published port is needed at all, and this is the safest of the
|
||||
two because there is no host port to reach even from the host.
|
||||
- **Proxy on the host**: set `THOUGHTSYNC_BIND=127.0.0.1` in `.env`, so the port
|
||||
exists but only the host itself can use it.
|
||||
|
||||
To check which you have: `docker compose ps` shows the published ports, and
|
||||
`curl http://<your-lan-ip>:5000/api/health` from another machine tells you whether the
|
||||
app is still answering around the proxy. It should not be.
|
||||
|
||||
**5. Have a backup that includes the files.** Attachments are files on the
|
||||
`thoughtsync-data` volume, not rows — a `pg_dump` restores notes whose images are all
|
||||
gone. Back up both:
|
||||
|
||||
@@ -57,9 +85,10 @@ docker run --rm -v thoughtsync-data:/d -v "$PWD":/out alpine tar czf /out/media.
|
||||
|
||||
- **The credential endpoints are throttled.** `/api/auth/login`, `/api/auth/register`
|
||||
and `/api/auth/device-login` count attempts against both the account and the calling
|
||||
address, and answer `429` with a `Retry-After` once either is over budget — ten
|
||||
failed sign-ins per account per fifteen minutes, five registrations per address per
|
||||
hour. The account-keyed limit is the one that holds when the address is forged.
|
||||
address, and answer `429` with a `Retry-After` once either is over budget. The
|
||||
numbers live in **Settings → Security** — ten failed sign-ins per account per
|
||||
fifteen minutes and five sign-ups per address per hour by default — and a change
|
||||
applies to the next attempt rather than the next deploy. The account-keyed limit is the one that holds when the address is forged.
|
||||
Checked *before* the password is verified, so a throttled attempt costs no bcrypt:
|
||||
hashing is deliberately slow, and an unauthenticated caller who can trigger it
|
||||
without limit has a CPU-exhaustion primitive as well as a guessing one.
|
||||
@@ -87,7 +116,14 @@ Know these before you decide who gets an account.
|
||||
- **No second factor.** A password is the whole of it.
|
||||
- **No per-user storage quota.** Any account can upload attachments until the volume
|
||||
is full. `max_attachment_mb` caps a single file, not a total.
|
||||
- **No audit log.** Device tokens record `last_used_at`; sign-ins are not recorded.
|
||||
- **No audit TABLE.** Credential events — sign-ins, failures, throttle trips, new
|
||||
accounts, device tokens issued — are written to the application log and readable
|
||||
with `docker compose logs app`, which is enough to see whether anyone is knocking.
|
||||
They are not queryable, not retained beyond the container's log rotation, and not
|
||||
attributable after the fact.
|
||||
- **No invites.** Adding a second person means re-opening registration while they
|
||||
sign up, then closing it again. There is no per-person token, no expiry, and no
|
||||
record of who invited whom.
|
||||
|
||||
None of these are hard blockers for an instance whose accounts are you and people you
|
||||
know. They are the reason not to hand out open registration to strangers.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
|
||||
// yet (account auth, device linking, attachment upload, URL unfurl, file import) —
|
||||
// those reject with a clear message rather than silently failing; the board, editor,
|
||||
// capture, search, filters, labels, checklists and reminders all work fully offline.
|
||||
// capture, filters, labels, checklists and reminders all work fully offline.
|
||||
|
||||
import { invoke } from "../desktop/bridge";
|
||||
import type { Note, NoteRevision } from "../stores/notes";
|
||||
@@ -71,7 +71,6 @@ export const local: Repo = {
|
||||
restoreRevision: (id, revId) => invoke<Note>("notes_restore_revision", { id, revId }),
|
||||
reminders: () => invoke<Note[]>("notes_reminders"),
|
||||
titles: () => invoke<TitleEntry[]>("notes_titles"),
|
||||
search: (q) => invoke<Note[]>("notes_search", { q }),
|
||||
},
|
||||
|
||||
savedFilters: {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// stays in the stores — the repo is data access only.
|
||||
|
||||
import type { NoteColor } from "../notes/colors";
|
||||
import type { Note, NoteFacets, NoteView, NoteKind, NoteRevision } from "../stores/notes";
|
||||
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
|
||||
import type { Label } from "../stores/labels";
|
||||
import type { SavedFilter } from "../stores/savedFilters";
|
||||
import type { Device } from "../stores/devices";
|
||||
@@ -32,16 +32,14 @@ export interface NoteListQuery {
|
||||
}
|
||||
|
||||
export interface NoteCreateInput {
|
||||
title: string;
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
kind?: NoteKind;
|
||||
items?: string[];
|
||||
}
|
||||
|
||||
// The mutable subset of a note (PATCH /api/notes/:id).
|
||||
export type NoteChanges = Partial<
|
||||
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
>;
|
||||
|
||||
export interface ChecklistItemChanges {
|
||||
@@ -113,7 +111,6 @@ export interface NotesRepo {
|
||||
restoreRevision(id: string, revId: string): Promise<Note>;
|
||||
reminders(): Promise<Note[]>;
|
||||
titles(): Promise<TitleEntry[]>;
|
||||
search(q: string): Promise<Note[]>;
|
||||
}
|
||||
|
||||
export interface SavedFiltersRepo {
|
||||
|
||||
@@ -32,7 +32,6 @@ function notesQuery(q: NoteListQuery): string {
|
||||
for (const id of q.facets?.label ?? []) if (id) params.append("label", id);
|
||||
if (q.facets?.q) params.set("q", q.facets.q);
|
||||
if (q.facets?.color) params.set("color", q.facets.color);
|
||||
if (q.facets?.kind) params.set("kind", q.facets.kind);
|
||||
if (q.facets?.has_reminder) params.set("has_reminder", "true");
|
||||
if (q.facets?.has_attachment) params.set("has_attachment", "true");
|
||||
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
|
||||
@@ -100,7 +99,6 @@ export const rest: Repo = {
|
||||
restoreRevision: (id, revId) => api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`),
|
||||
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
|
||||
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
|
||||
search: async (q) => (await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`)).notes,
|
||||
},
|
||||
|
||||
savedFilters: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useLabelsStore } from "../stores/labels";
|
||||
@@ -177,21 +177,48 @@ function labelDot(color: string): string {
|
||||
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
||||
}
|
||||
|
||||
// The board lenses — the routes a search can happen *within*. Searching while looking
|
||||
// at Trash should search Trash, not silently move you.
|
||||
const BOARD_ROUTES = new Set(["board", "archive", "trash", "label"]);
|
||||
|
||||
/**
|
||||
* Search is a FACET, not a destination.
|
||||
*
|
||||
* It used to navigate to a `/search` view backed by a different endpoint with no
|
||||
* facets at all — so the one screen you landed on when you searched was the one
|
||||
* screen where you could not also narrow by tag, which is precisely what tags are
|
||||
* for (note 2930). Now it writes `?q=` into the board's URL, beside any labels
|
||||
* already there, and the same AND-ed query serves both.
|
||||
*
|
||||
* Existing facets are preserved, so "filter by #grocery, then search" and the reverse
|
||||
* both work.
|
||||
*/
|
||||
function onSearch(value: string) {
|
||||
searchText.value = value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
const q = searchText.value.trim();
|
||||
if (q) router.push({ name: "search", query: { q } });
|
||||
else if (route.name === "search") router.push("/");
|
||||
const onBoard = BOARD_ROUTES.has(String(route.name));
|
||||
const query: LocationQueryRaw = onBoard ? { ...route.query } : {};
|
||||
if (q) query.q = q;
|
||||
else delete query.q;
|
||||
void router.push({ path: onBoard ? route.path : "/", query });
|
||||
}, 250);
|
||||
}
|
||||
|
||||
// Clear the search box when navigating to a non-search view.
|
||||
// The URL is the filter state (see notes/facets.ts), so the box READS from it rather
|
||||
// than holding its own copy — which is also what keeps it in step with the Filters
|
||||
// panel's Clear button and with a saved view opened from the sidebar.
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(q) => {
|
||||
searchText.value = typeof q === "string" ? q : "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => route.name,
|
||||
(name) => {
|
||||
if (name !== "search") searchText.value = "";
|
||||
() => {
|
||||
drawer.value = false;
|
||||
},
|
||||
);
|
||||
@@ -200,7 +227,7 @@ watch(
|
||||
* What to call the lens currently in view.
|
||||
*
|
||||
* Keyed off the route name rather than each view declaring its own title, so the
|
||||
* label sits in one place and can't go missing (the board and search never had one)
|
||||
* label sits in one place and can't go missing (the board never had one)
|
||||
* or drift in styling (timeline and reminders each had their own h1).
|
||||
*
|
||||
* A label lens is named by the label itself — "Groceries" is what the user came
|
||||
@@ -212,8 +239,6 @@ const lensName = computed<string>(() => {
|
||||
return "Archive";
|
||||
case "trash":
|
||||
return "Trash";
|
||||
case "search":
|
||||
return "Search";
|
||||
case "timeline":
|
||||
return "Timeline";
|
||||
case "reminders":
|
||||
@@ -286,7 +311,7 @@ async function signOut() {
|
||||
page you navigated to — so it sits in the bar that never moves, beside
|
||||
the app name, and stays in one place while everything beneath it
|
||||
re-filters. Replaces the per-view <h1>s, which sat in a different spot
|
||||
in each view and were absent entirely on the board and in search. -->
|
||||
in each view and were absent entirely on the board. -->
|
||||
<span aria-live="polite" class="flex min-w-0 shrink items-center gap-2 text-sm text-neutral-400">
|
||||
<!-- The separator only makes sense next to the app name, which is itself
|
||||
hidden on narrow screens. There, the lens name simply takes the space
|
||||
@@ -511,7 +536,7 @@ async function signOut() {
|
||||
BoardView, so keying on the route would remount it — blanking the board
|
||||
and refetching, which is precisely the page-change feeling this is meant
|
||||
to remove. Unkeyed, Vue only transitions when the component TYPE changes
|
||||
(board ↔ search ↔ timeline), and moving between the board's own
|
||||
(board ↔ timeline ↔ reminders), and moving between the board's own
|
||||
lenses stays an in-place reflow that NoteGrid animates. -->
|
||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
||||
<RouterView v-slot="{ Component }">
|
||||
|
||||
@@ -10,8 +10,8 @@ import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime"
|
||||
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
// A dead-simple facet bar over the board: text search + color + labels + has-reminder
|
||||
// + has-attachment + kind + created-date range. The URL query IS the state, so a
|
||||
// A dead-simple facet bar over the board: color + labels + has-reminder
|
||||
// + has-attachment + created-date range. The URL query IS the state, so a
|
||||
// filtered board is a shareable lens and a saved view is just a link.
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -35,9 +35,6 @@ function clearAll() {
|
||||
function setColor(c: NoteColor) {
|
||||
patch({ color: facets.value.color === c ? undefined : c });
|
||||
}
|
||||
function setKind(k: "text" | "list") {
|
||||
patch({ kind: facets.value.kind === k ? undefined : k });
|
||||
}
|
||||
function toggleLabel(id: string) {
|
||||
const cur = facets.value.label ?? [];
|
||||
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||
@@ -50,13 +47,6 @@ function toggleAttachment() {
|
||||
patch({ has_attachment: facets.value.has_attachment ? undefined : true });
|
||||
}
|
||||
|
||||
let qTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function onQ(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(qTimer);
|
||||
qTimer = setTimeout(() => patch({ q: v.trim() || undefined }), 300);
|
||||
}
|
||||
|
||||
function onFrom(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
patch({ created_after: v ? `${v}T00:00:00` : undefined });
|
||||
@@ -121,14 +111,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
|
||||
v-if="open"
|
||||
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
:value="facets.q ?? ''"
|
||||
placeholder="Search text…"
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
|
||||
@input="onQ"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
|
||||
<button
|
||||
@@ -164,12 +146,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
|
||||
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
|
||||
Has attachment
|
||||
</button>
|
||||
<button type="button" :class="[chipBase, facets.kind === 'list' ? chipOn : chipOff]" @click="setKind('list')">
|
||||
Lists
|
||||
</button>
|
||||
<button type="button" :class="[chipBase, facets.kind === 'text' ? chipOn : chipOff]" @click="setKind('text')">
|
||||
Notes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { LinkPreview } from "../stores/notes";
|
||||
|
||||
defineProps<{ preview: LinkPreview; removable?: boolean }>();
|
||||
/**
|
||||
* A fetched link preview, in one of two sizes.
|
||||
*
|
||||
* `compact` is a single row — favicon-less, one line of title, the site name — for a
|
||||
* URL mentioned *inside* a note that has its own text. The note is the thing; the
|
||||
* link is a footnote to it.
|
||||
*
|
||||
* Full size is for a note that is NOTHING but a URL. There the link IS the note, and
|
||||
* a compact strip would be a card with nothing on it.
|
||||
*/
|
||||
defineProps<{ preview: LinkPreview; removable?: boolean; compact?: boolean }>();
|
||||
defineEmits<{ (e: "remove"): void }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group/lp relative overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||
<div
|
||||
class="group/lp relative overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700"
|
||||
>
|
||||
<a
|
||||
:href="preview.url"
|
||||
target="_blank"
|
||||
@@ -19,16 +31,28 @@ defineEmits<{ (e: "remove"): void }>();
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-auto w-24 shrink-0 self-stretch object-cover"
|
||||
class="h-auto shrink-0 self-stretch object-cover"
|
||||
:class="compact ? 'w-12' : 'w-24'"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 px-3 py-2">
|
||||
<p v-if="preview.site_name" class="truncate text-[11px] uppercase tracking-wide text-neutral-400">
|
||||
<div class="min-w-0 flex-1" :class="compact ? 'px-2 py-1.5' : 'px-3 py-2'">
|
||||
<p
|
||||
v-if="preview.site_name"
|
||||
class="truncate uppercase tracking-wide text-neutral-400"
|
||||
:class="compact ? 'text-[10px]' : 'text-[11px]'"
|
||||
>
|
||||
{{ preview.site_name }}
|
||||
</p>
|
||||
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||
<p
|
||||
class="truncate font-medium text-neutral-800 dark:text-neutral-100"
|
||||
:class="compact ? 'text-xs' : 'text-sm'"
|
||||
>
|
||||
{{ preview.title || preview.url }}
|
||||
</p>
|
||||
<p v-if="preview.description" class="mt-0.5 line-clamp-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<!-- The description is the first thing to go when there is no room for it. -->
|
||||
<p
|
||||
v-if="preview.description && !compact"
|
||||
class="mt-0.5 line-clamp-2 text-xs text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{{ preview.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,46 @@ const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <
|
||||
const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
|
||||
const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.mime.startsWith("image/")));
|
||||
|
||||
// How much of a note the CARD shows. Android has always clamped to 8
|
||||
// (`MAX_PREVIEW_LINES`); the web rendered the whole body, so one long note could
|
||||
// produce a card taller than the screen and push everything else off the board.
|
||||
//
|
||||
// It matters more now that the title is gone (M13 step 4). The first line used to be
|
||||
// the thing your eye caught; with one weight throughout, an unbounded card is just a
|
||||
// wall, and the note next to it is the one you were looking for.
|
||||
//
|
||||
// Clamped in the STRING rather than with CSS `line-clamp`, which needs a
|
||||
// `-webkit-box` and behaves unreliably around the block elements MarkdownText emits
|
||||
// (lists, quotes, fenced code). This is deterministic, matches Android's semantics
|
||||
// exactly, and skips parsing a body the card was never going to show.
|
||||
const PREVIEW_LINES = 8;
|
||||
|
||||
// --- Links ------------------------------------------------------------------
|
||||
//
|
||||
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
|
||||
// showing the raw URL underneath a card that already says where it goes is saying the
|
||||
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
|
||||
// gets a compact strip at the bottom instead.
|
||||
//
|
||||
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
|
||||
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
|
||||
|
||||
const isLoneUrl = computed(() => LONE_URL_RE.test(props.note.body) && !props.note.items.length);
|
||||
|
||||
/** The preview for a lone-URL note — null while it is still being fetched, or if it
|
||||
* could never be fetched at all. */
|
||||
const loneUrlPreview = computed(() => {
|
||||
if (!isLoneUrl.value) return null;
|
||||
const url = props.note.body.trim();
|
||||
return props.note.previews.find((p) => p.url === url) ?? null;
|
||||
});
|
||||
|
||||
const bodyPreview = computed(() => {
|
||||
const lines = props.note.body.split("\n");
|
||||
if (lines.length <= PREVIEW_LINES) return props.note.body;
|
||||
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
|
||||
});
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
|
||||
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
|
||||
@@ -206,43 +246,46 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="note.previews.length" class="mb-2 flex flex-col gap-2">
|
||||
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" />
|
||||
</div>
|
||||
|
||||
<!-- Checklist notes can't nest interactive controls in a <button>, so use a
|
||||
focusable div; text notes keep a semantic button. -->
|
||||
<template v-if="note.kind === 'list'">
|
||||
<!-- One render path: every note is a body plus, maybe, checkable items.
|
||||
A focusable div rather than a <button>, because a checklist nests interactive
|
||||
controls and those cannot live inside a button — and the card is the same
|
||||
shape whether or not it happens to carry items today. -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
@click="emit('open', note)"
|
||||
@keydown.enter="emit('open', note)"
|
||||
>
|
||||
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{{ note.title }}
|
||||
</h3>
|
||||
<!-- A lone URL renders as its preview and nothing else. Until the fetch lands
|
||||
— or if it never does — the URL itself stands in, so the card is never
|
||||
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" />
|
||||
</div>
|
||||
<NoteChecklist class="mt-1" :note-id="note.id" :items="note.items" @click="emit('open', note)" />
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="block w-full cursor-text rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
|
||||
@click="emit('open', note)"
|
||||
<p
|
||||
v-if="!note.body && !note.items.length && !note.attachments.length"
|
||||
class="text-sm italic text-neutral-400"
|
||||
>
|
||||
<h3 v-if="note.title" class="mb-1 break-words text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{{ note.title }}
|
||||
</h3>
|
||||
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<MarkdownText :text="note.body" />
|
||||
</div>
|
||||
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
||||
Empty note
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Inline links: a compact strip at the FOOT of the card, under the note's own
|
||||
words rather than stacked on top of them. They were above the body until
|
||||
M13 — which put a stranger's headline where the note's first line should be. -->
|
||||
<div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1">
|
||||
<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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import ColorPicker from "./ColorPicker.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
import LabelPicker from "./LabelPicker.vue";
|
||||
@@ -24,14 +23,15 @@ const props = withDefaults(defineProps<{ note?: Note | null; initialBody?: strin
|
||||
});
|
||||
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||
const notes = useNotesStore();
|
||||
const config = useConfigStore();
|
||||
|
||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||
const title = ref(props.note?.title ?? "");
|
||||
const body = ref(props.note?.body ?? props.initialBody);
|
||||
const color = ref<NoteColor>(props.note?.color ?? "default");
|
||||
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
|
||||
const createKind = ref<"text" | "list">("text"); // compose-only list toggle
|
||||
// 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);
|
||||
@@ -39,26 +39,23 @@ const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const uploadError = ref("");
|
||||
|
||||
// Baseline for edit-mode change detection (save only when text actually changed).
|
||||
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
|
||||
title: props.note?.title ?? null,
|
||||
const baseline = ref<{ body: string; color: NoteColor }>({
|
||||
body: props.note?.body ?? "",
|
||||
color: (props.note?.color ?? "default") as NoteColor,
|
||||
});
|
||||
|
||||
const isCreate = computed(() => noteId.value === null);
|
||||
const hasContent = computed(() => title.value.trim() !== "" || body.value.trim() !== "");
|
||||
const hasContent = computed(() => body.value.trim() !== "");
|
||||
// Rich features need a saved note; in compose they light up once there's content.
|
||||
const richEnabled = computed(() => !isCreate.value || hasContent.value);
|
||||
|
||||
// A synthetic note for compose mode (before anything is persisted), so the shared
|
||||
// template can read attachments/items/kind/remind_at uniformly.
|
||||
// template can read attachments/items/remind_at uniformly.
|
||||
const draftNote = computed<Note>(() => ({
|
||||
id: "",
|
||||
title: title.value.trim() || null,
|
||||
display_title: "",
|
||||
body: body.value,
|
||||
color: color.value,
|
||||
kind: createKind.value,
|
||||
position: 0,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
@@ -78,42 +75,34 @@ const liveNote = computed<Note>(() =>
|
||||
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
|
||||
: draftNote.value,
|
||||
);
|
||||
// Only edit-mode list notes render the interactive checklist; compose-list types
|
||||
// lines into the textarea (they become items on create).
|
||||
const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list");
|
||||
const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list"));
|
||||
const bodyPlaceholder = computed(() =>
|
||||
isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)",
|
||||
// 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).
|
||||
watch(
|
||||
() => props.note,
|
||||
(n) => {
|
||||
noteId.value = n?.id ?? null;
|
||||
title.value = n?.title ?? "";
|
||||
body.value = n?.body ?? "";
|
||||
color.value = (n?.color ?? "default") as NoteColor;
|
||||
labelList.value = n ? [...n.labels] : [];
|
||||
baseline.value = { title: n?.title ?? null, body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
||||
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
|
||||
},
|
||||
);
|
||||
|
||||
// ---- persistence ----
|
||||
async function createFromFields(): Promise<void> {
|
||||
let created: Note;
|
||||
if (createKind.value === "list") {
|
||||
const items = body.value
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
created = await notes.create({ title: title.value, body: "", color: color.value, kind: "list", items });
|
||||
body.value = ""; // the lines moved into checklist items
|
||||
} else {
|
||||
created = await notes.create({ title: title.value, body: body.value, color: color.value });
|
||||
}
|
||||
const created = await notes.create({ body: body.value, color: color.value });
|
||||
noteId.value = created.id;
|
||||
baseline.value = { title: created.title, body: created.body, color: created.color as NoteColor };
|
||||
baseline.value = { body: created.body, color: created.color as NoteColor };
|
||||
}
|
||||
|
||||
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
|
||||
@@ -139,13 +128,13 @@ async function flush(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const b = baseline.value;
|
||||
const nextBody = showChecklist.value ? b.body : body.value;
|
||||
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color;
|
||||
const nextBody = body.value;
|
||||
const changed = nextBody !== b.body || color.value !== b.color;
|
||||
if (!changed) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value });
|
||||
baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value };
|
||||
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
|
||||
baseline.value = { body: nextBody, color: color.value };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -153,12 +142,11 @@ async function flush(): Promise<void> {
|
||||
|
||||
function resetCompose(): void {
|
||||
noteId.value = null;
|
||||
title.value = "";
|
||||
body.value = "";
|
||||
color.value = "default";
|
||||
labelList.value = [];
|
||||
createKind.value = "text";
|
||||
baseline.value = { title: null, body: "", color: "default" };
|
||||
checklistOpen.value = false;
|
||||
baseline.value = { body: "", color: "default" };
|
||||
uploadError.value = "";
|
||||
}
|
||||
|
||||
@@ -254,12 +242,6 @@ function onBodyKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function onTitleEnter(e: KeyboardEvent) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey && isCreate.value) void commitAndContinue();
|
||||
else bodyInput.value?.focus();
|
||||
}
|
||||
|
||||
// ---- reminder ----
|
||||
const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at));
|
||||
async function onReminderChange(e: Event) {
|
||||
@@ -301,29 +283,16 @@ function labelChip(c: string): string {
|
||||
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
|
||||
}
|
||||
|
||||
// ---- kind toggle: compose = local flag, edit = convert the existing note ----
|
||||
async function toggleKind() {
|
||||
if (isCreate.value) {
|
||||
createKind.value = createKind.value === "list" ? "text" : "list";
|
||||
bodyInput.value?.focus();
|
||||
return;
|
||||
}
|
||||
const id = noteId.value as string;
|
||||
if (liveNote.value.kind === "list") {
|
||||
await notes.setKind(id, "text");
|
||||
return;
|
||||
}
|
||||
const lines = body.value
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
for (const line of lines) await notes.addItem(id, line);
|
||||
if (lines.length > 0) {
|
||||
body.value = "";
|
||||
await notes.saveEdit(id, { title: title.value, body: "", color: color.value });
|
||||
baseline.value = { title: title.value.trim() || null, body: "", color: color.value };
|
||||
}
|
||||
await notes.setKind(id, "list");
|
||||
// ---- 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;
|
||||
}
|
||||
|
||||
// ---- attachments ----
|
||||
@@ -352,41 +321,12 @@ async function uploadFile(file: File) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- link previews (URL unfurl) ----
|
||||
const unfurling = ref<string | null>(null); // the URL currently being fetched
|
||||
const unfurlError = ref("");
|
||||
// Bare http(s) URLs in the body; trailing sentence punctuation trimmed.
|
||||
const URL_RE = /(https?:\/\/[^\s<>"'\])]+)/g;
|
||||
const detectedUrls = computed(() => {
|
||||
const out: string[] = [];
|
||||
for (const m of body.value.matchAll(URL_RE)) {
|
||||
const u = m[1].replace(/[.,;:!?]+$/, "");
|
||||
if (!out.includes(u)) out.push(u);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const previewedUrls = computed(() => new Set(liveNote.value.previews.map((p) => p.url)));
|
||||
const unpreviewedUrls = computed(() => detectedUrls.value.filter((u) => !previewedUrls.value.has(u)));
|
||||
async function addPreview(url: string) {
|
||||
const id = await ensureDraft();
|
||||
if (!id) return;
|
||||
unfurling.value = url;
|
||||
unfurlError.value = "";
|
||||
try {
|
||||
await notes.unfurl(id, url);
|
||||
} catch (e) {
|
||||
unfurlError.value = (e as { error?: string }).error ?? "Couldn't fetch a preview for that link.";
|
||||
} finally {
|
||||
unfurling.value = null;
|
||||
}
|
||||
}
|
||||
function shortUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
// ---- link previews ----
|
||||
//
|
||||
// Nothing to trigger any more: the server unfurls a note's URLs in the background
|
||||
// after each save (`unfurl_queue.py`) and the preview arrives on a later read. What
|
||||
// is left here is removing one you don't want — the editor is the only place with
|
||||
// room to offer that, and the card deliberately doesn't.
|
||||
async function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -431,10 +371,9 @@ async function restoreRevisionAt(revId: string) {
|
||||
const id = noteId.value;
|
||||
if (!id) return;
|
||||
const updated = await notes.restoreRevision(id, revId);
|
||||
title.value = updated.title ?? "";
|
||||
body.value = updated.body;
|
||||
color.value = updated.color;
|
||||
baseline.value = { title: updated.title, body: updated.body, color: updated.color };
|
||||
baseline.value = { body: updated.body, color: updated.color };
|
||||
void loadRevisions(); // the pre-restore state became a new revision
|
||||
}
|
||||
function revLabel(iso: string | null): string {
|
||||
@@ -442,9 +381,7 @@ function revLabel(iso: string | null): string {
|
||||
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
function revPreview(rev: NoteRevision): string {
|
||||
const t = (rev.title ?? "").trim();
|
||||
const b = rev.body.trim().replace(/\s+/g, " ");
|
||||
const s = t && b ? `${t} — ${b}` : t || b;
|
||||
const s = rev.body.trim().replace(/\s+/g, " ");
|
||||
if (!s) return "(empty)";
|
||||
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
|
||||
}
|
||||
@@ -526,7 +463,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
</div>
|
||||
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
|
||||
|
||||
<!-- Link previews: stored preview cards + one "Preview <domain>" per detected URL -->
|
||||
<!-- Fetched automatically after each save; removable here and nowhere else. -->
|
||||
<div v-if="liveNote.previews.length" class="flex flex-col gap-2">
|
||||
<LinkPreview
|
||||
v-for="p in liveNote.previews"
|
||||
@@ -536,34 +473,8 @@ function revPreview(rev: NoteRevision): string {
|
||||
@remove="notes.deletePreview(liveNote.id, p.id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="config.enableUrlUnfurl && !liveNote.trashed && unpreviewedUrls.length"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<button
|
||||
v-for="u in unpreviewedUrls"
|
||||
:key="u"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-neutral-200 px-2 py-0.5 text-xs text-neutral-500 hover:bg-neutral-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
:disabled="unfurling === u"
|
||||
@click="addPreview(u)"
|
||||
>
|
||||
<Icon name="link" />
|
||||
{{ unfurling === u ? "Fetching…" : `Preview ${shortUrl(u)}` }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="unfurlError" class="text-xs text-red-600 dark:text-red-400">{{ unfurlError }}</p>
|
||||
|
||||
<input
|
||||
v-model="title"
|
||||
type="text"
|
||||
placeholder="Title (optional)"
|
||||
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400"
|
||||
@keydown.enter="onTitleEnter"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-if="!showChecklist"
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
@@ -571,7 +482,14 @@ function revPreview(rev: NoteRevision): string {
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@keydown="onBodyKeydown"
|
||||
/>
|
||||
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
|
||||
<!-- Below the body, not instead of it. -->
|
||||
<NoteChecklist
|
||||
v-if="showChecklist"
|
||||
class="py-1"
|
||||
:note-id="liveNote.id"
|
||||
:items="liveNote.items"
|
||||
editable
|
||||
/>
|
||||
|
||||
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
||||
<span
|
||||
@@ -640,7 +558,6 @@ function revPreview(rev: NoteRevision): string {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isCreate && showHistory"
|
||||
@@ -681,13 +598,12 @@ function revPreview(rev: NoteRevision): string {
|
||||
</button>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
|
||||
<button
|
||||
v-if="!liveNote.trashed"
|
||||
v-if="richEnabled && !liveNote.trashed && !showChecklist"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''"
|
||||
:title="isListMode ? 'Switch to a note' : 'Make a checklist'"
|
||||
:aria-pressed="isListMode"
|
||||
@click="toggleKind"
|
||||
title="Add a checklist"
|
||||
aria-label="Add a checklist"
|
||||
@click="addChecklist"
|
||||
>
|
||||
<Icon name="checkbox" />
|
||||
</button>
|
||||
@@ -763,6 +679,5 @@ function revPreview(rev: NoteRevision): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -17,8 +17,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
|
||||
if (text) f.q = text;
|
||||
const color = one(q.color);
|
||||
if (color) f.color = color;
|
||||
const kind = one(q.kind);
|
||||
if (kind === "text" || kind === "list") f.kind = kind;
|
||||
if (labels.length) f.label = labels;
|
||||
if (one(q.has_reminder) === "true") f.has_reminder = true;
|
||||
if (one(q.has_attachment) === "true") f.has_attachment = true;
|
||||
@@ -33,7 +31,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
|
||||
const q: LocationQueryRaw = {};
|
||||
if (f.q) q.q = f.q;
|
||||
if (f.color) q.color = f.color;
|
||||
if (f.kind) q.kind = f.kind;
|
||||
if (f.label?.length) q.label = f.label;
|
||||
if (f.has_reminder) q.has_reminder = "true";
|
||||
if (f.has_attachment) q.has_attachment = "true";
|
||||
@@ -47,7 +44,6 @@ export function facetCount(f: NoteFacets): number {
|
||||
let n = 0;
|
||||
if (f.q) n++;
|
||||
if (f.color) n++;
|
||||
if (f.kind) n++;
|
||||
n += f.label?.length ?? 0;
|
||||
if (f.has_reminder) n++;
|
||||
if (f.has_attachment) n++;
|
||||
|
||||
@@ -20,7 +20,6 @@ const router = createRouter({
|
||||
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
||||
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
||||
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
|
||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
||||
],
|
||||
|
||||
@@ -5,14 +5,11 @@ import { useUiStore } from "./ui";
|
||||
import type { NoteColor } from "../notes/colors";
|
||||
|
||||
export type NoteView = "active" | "archived" | "trash";
|
||||
export type NoteKind = "text" | "list";
|
||||
|
||||
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved
|
||||
// view's stored params). All optional; empty = the plain, unfiltered board.
|
||||
export interface NoteFacets {
|
||||
q?: string;
|
||||
color?: string;
|
||||
kind?: NoteKind;
|
||||
label?: string[];
|
||||
has_reminder?: boolean;
|
||||
has_attachment?: boolean;
|
||||
@@ -55,23 +52,20 @@ export interface LinkPreview {
|
||||
site_name: string | null;
|
||||
}
|
||||
|
||||
// A past version of a note's title+body (version history).
|
||||
// A past version of a note's body (version history).
|
||||
export interface NoteRevision {
|
||||
id: string;
|
||||
title: string | null;
|
||||
body: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string | null;
|
||||
// The note's display NAME: explicit title, else its first body line (server-derived).
|
||||
// Every note has one, so a body-only note still has something to be called.
|
||||
// The note's NAME: its first body line, else its first checklist item
|
||||
// (server-derived). Every note has one, so every note has something to be called.
|
||||
display_title: string;
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
kind: NoteKind;
|
||||
position: number;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
@@ -138,10 +132,8 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
}
|
||||
|
||||
async function create(input: {
|
||||
title: string;
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
kind?: NoteKind;
|
||||
items?: string[];
|
||||
}): Promise<Note> {
|
||||
const note = await repo.notes.create(input);
|
||||
@@ -152,7 +144,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
async function mutate(
|
||||
id: string,
|
||||
changes: Partial<
|
||||
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
>,
|
||||
): Promise<void> {
|
||||
reconcile(await repo.notes.update(id, changes));
|
||||
@@ -165,10 +157,9 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
|
||||
};
|
||||
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
||||
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
|
||||
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
|
||||
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
|
||||
const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes);
|
||||
const saveEdit = (id: string, changes: { body: string; color: NoteColor }) => mutate(id, changes);
|
||||
|
||||
async function completeReminder(id: string): Promise<void> {
|
||||
reconcile(await repo.notes.completeReminder(id));
|
||||
@@ -284,7 +275,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
setPinned,
|
||||
setArchived,
|
||||
setColor,
|
||||
setKind,
|
||||
setReminder,
|
||||
setRecurrence,
|
||||
completeReminder,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { repo } from "../adapters";
|
||||
import { useNoteList } from "../composables/useNoteList";
|
||||
import { useNoteEditor } from "../composables/useNoteEditor";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import EmptyState from "../components/EmptyState.vue";
|
||||
import NoteGrid from "../components/NoteGrid.vue";
|
||||
import NoteEditor from "../components/NoteEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const query = computed(() => (typeof route.query.q === "string" ? route.query.q : ""));
|
||||
const noMatchSubtitle = computed(() => `Nothing found for "${query.value}".`);
|
||||
|
||||
const { items: results, loading, error, load: run } = useNoteList(async () => {
|
||||
const q = query.value.trim();
|
||||
if (!q) return [];
|
||||
return repo.notes.search(q);
|
||||
}, "Search failed.");
|
||||
|
||||
const { editing, open: openEditor, close: closeEditor, navigate: onNavigate } = useNoteEditor({
|
||||
list: () => results.value,
|
||||
onClose: run, // reflect any edits made from a result
|
||||
});
|
||||
|
||||
watch(query, run, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-6">
|
||||
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<template v-if="query"
|
||||
>Results for <span class="font-semibold text-neutral-800 dark:text-neutral-200">{{ query }}</span></template
|
||||
>
|
||||
<template v-else>Type in the search box to find your notes.</template>
|
||||
</p>
|
||||
|
||||
<AsyncState :loading="loading" :error="error || undefined" error-title="Couldn't search" @retry="run">
|
||||
<EmptyState v-if="query && results.length === 0" title="No matches" :subtitle="noMatchSubtitle" />
|
||||
<NoteGrid v-else-if="results.length" :notes="results" @open="openEditor" />
|
||||
</AsyncState>
|
||||
</div>
|
||||
|
||||
<template v-if="editing">
|
||||
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
|
||||
</template>
|
||||
</template>
|
||||
@@ -12,6 +12,10 @@ interface SettingItem {
|
||||
label: string;
|
||||
description: string;
|
||||
group: string;
|
||||
// Ints only, and nullable: the server sends the registry's bounds so the number
|
||||
// input can refuse an out-of-range value before the round trip.
|
||||
minimum: number | null;
|
||||
maximum: number | null;
|
||||
}
|
||||
|
||||
const config = useConfigStore();
|
||||
@@ -131,10 +135,16 @@ onMounted(load);
|
||||
:checked="Boolean(it.value)"
|
||||
@change="it.value = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<!-- min/max come from the registry. The server rejects out-of-range
|
||||
values regardless — this is so the browser says so first, rather than
|
||||
letting someone type a hop count that would disable a protection and
|
||||
only learn about it from an error banner. -->
|
||||
<input
|
||||
v-else-if="it.type === 'int'"
|
||||
:id="it.key"
|
||||
type="number"
|
||||
:min="it.minimum ?? undefined"
|
||||
:max="it.maximum ?? undefined"
|
||||
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="Number(it.value)"
|
||||
@input="it.value = Number(($event.target as HTMLInputElement).value)"
|
||||
|
||||
+7
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "thoughtsync"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Self-hosted personal thought-capture web app (FabledSword family)"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
@@ -29,6 +29,12 @@ where = ["src"]
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
# The unit lane runs `-m "not integration"`; the integration lane runs `-m integration`
|
||||
# against a real Postgres. Registered here so an unmarked typo fails loudly instead of
|
||||
# quietly landing a test in neither lane.
|
||||
markers = [
|
||||
"integration: needs a live Postgres — CI's integration job, not the unit lane",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
+23
-17
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
|
||||
from quart import Quart, has_request_context, jsonify, request, send_from_directory
|
||||
from quart import Quart, jsonify, send_from_directory
|
||||
from quart.sessions import SecureCookieSessionInterface
|
||||
|
||||
from . import __version__
|
||||
@@ -17,12 +18,25 @@ from .config import Config
|
||||
from .db import session_scope
|
||||
from .labels import bp as labels_bp
|
||||
from .notes import bp as notes_bp
|
||||
from .proxy import is_https
|
||||
from .retention import run_sweeper
|
||||
from .saved_filters import bp as saved_filters_bp
|
||||
from .settings import get_public_config, get_setting, load_or_create_secret_key
|
||||
from .settings import get_public_config, get_setting, load_or_create_secret_key, refresh_live
|
||||
from .settings_api import bp as settings_bp
|
||||
from .sync import bp as sync_bp, protocol_advertisement
|
||||
|
||||
# Without this, `logger.info` from this package goes nowhere: hypercorn configures its
|
||||
# own access/error loggers and leaves the root logger at WARNING, so the credential
|
||||
# events in auth.py would be invisible in `docker compose logs` — which is exactly
|
||||
# where they are meant to be read until an audit table exists (task 2939).
|
||||
#
|
||||
# `force=False` (the default) so a host that has already configured logging keeps its
|
||||
# own setup; LOG_LEVEL lets an operator turn it up without a code change.
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("THOUGHTSYNC_LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
|
||||
# `.webmanifest` isn't in every base image's mime map; register it so the PWA
|
||||
@@ -30,19 +44,6 @@ STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||
|
||||
|
||||
def _is_https() -> bool:
|
||||
"""Whether this request reached us over TLS — directly, or through a proxy that
|
||||
terminated it and said so in X-Forwarded-Proto.
|
||||
|
||||
Shared by the session cookie's Secure flag and by HSTS, because they are the same
|
||||
question and answering it twice is how the two drift apart.
|
||||
"""
|
||||
if not has_request_context():
|
||||
return False
|
||||
forwarded = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip().lower()
|
||||
return forwarded == "https" or request.is_secure
|
||||
|
||||
|
||||
class _AutoSecureSessionInterface(SecureCookieSessionInterface):
|
||||
"""Mark the session cookie `Secure` whenever the request arrived over HTTPS —
|
||||
directly, or via a TLS-terminating reverse proxy that sets X-Forwarded-Proto.
|
||||
@@ -54,7 +55,7 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface):
|
||||
"""
|
||||
|
||||
def get_cookie_secure(self, app: Quart) -> bool:
|
||||
return _is_https()
|
||||
return is_https()
|
||||
|
||||
|
||||
def create_app() -> Quart:
|
||||
@@ -93,6 +94,11 @@ def create_app() -> Quart:
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
pass
|
||||
# The security settings the throttle and the proxy trust read on hot
|
||||
# paths. Cached rather than queried per request; until this runs they
|
||||
# hold their registry defaults, which is the correct behaviour for a
|
||||
# server that has not finished starting.
|
||||
await refresh_live(db)
|
||||
# Expire old trash in the background (retention.py). One task per process is
|
||||
# correct because the image serves with a single hypercorn worker (Dockerfile);
|
||||
# if that ever gains `--workers`, this needs a lock so N workers don't each
|
||||
@@ -168,7 +174,7 @@ def create_app() -> Quart:
|
||||
# commit domains this app does not own. A browser still remembers the policy
|
||||
# for up to a year after the header stops being sent, which is the point of
|
||||
# it — worth knowing before putting a hostname behind TLS temporarily.
|
||||
if _is_https():
|
||||
if is_https():
|
||||
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000")
|
||||
return response
|
||||
|
||||
|
||||
+42
-2
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -11,17 +12,27 @@ from .common import iso
|
||||
from .db import session_scope
|
||||
from .models.device_token import DeviceToken
|
||||
from .models.user import User
|
||||
from .proxy import client_address
|
||||
from .ratelimit import (
|
||||
client_address,
|
||||
register_by_address,
|
||||
sign_in_by_account,
|
||||
sign_in_by_address,
|
||||
)
|
||||
from .security import dummy_verify, generate_token, hash_password, hash_token, verify_password
|
||||
from .settings import get_setting
|
||||
from .settings import get_setting, set_settings
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
# Every credential event goes to the app log — there is no audit TABLE yet (see task
|
||||
# 2939), and until there is, `docker compose logs` is the only way to know whether
|
||||
# anyone is knocking. That matters most in exactly the window this was written for: a
|
||||
# freshly-exposed instance.
|
||||
#
|
||||
# The attempted email is included deliberately. It is the operator's own server, and
|
||||
# "somebody failed a login" without saying against WHICH account tells you nothing you
|
||||
# can act on. Passwords, obviously, never appear.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_KEY = "user_id"
|
||||
MIN_PASSWORD_LEN = 8
|
||||
DEVICE_NAME_CAP = 100
|
||||
@@ -120,6 +131,7 @@ def _throttled(retry_after: int):
|
||||
`Retry-After` is standard and is the one thing a legitimate client (or person)
|
||||
genuinely needs.
|
||||
"""
|
||||
logger.warning("throttled credential attempt from=%s retry_after=%ss", client_address(), retry_after)
|
||||
return (
|
||||
jsonify({"error": "too many attempts — try again shortly"}),
|
||||
429,
|
||||
@@ -186,6 +198,7 @@ async def register():
|
||||
# The first account bootstraps the admin and is always allowed, even when
|
||||
# registration is otherwise closed.
|
||||
if not is_first and not await get_setting(db, "allow_registration"):
|
||||
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "registration is closed"}), 403
|
||||
existing = await db.scalar(select(User).where(User.email == email))
|
||||
if existing is not None:
|
||||
@@ -197,10 +210,27 @@ async def register():
|
||||
is_admin=is_first,
|
||||
)
|
||||
db.add(user)
|
||||
if is_first:
|
||||
# Registration CLOSES the moment the instance has an owner.
|
||||
#
|
||||
# Not "defaults closed" — that would still need the first person to get in
|
||||
# somehow. Closed as a CONSEQUENCE of the admin account existing, which is
|
||||
# the only formulation with no open window in it. Leaving the setting on
|
||||
# meant the gap between "my account exists" and "I remembered to turn it
|
||||
# off in Settings" was wide open, and on a public host that gap is the
|
||||
# entire exposure — it starts the moment DNS resolves.
|
||||
#
|
||||
# An admin who wants a second person turns it back on in Settings → Access,
|
||||
# adds them, and turns it off. Crude until invites exist, but it is a
|
||||
# deliberate act rather than a default nobody chose.
|
||||
await set_settings(db, {"allow_registration": False})
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
session[SESSION_KEY] = str(user.id)
|
||||
session.permanent = True
|
||||
logger.info(
|
||||
"account created email=%s admin=%s from=%s", email, is_first, client_address()
|
||||
)
|
||||
return jsonify(_serialize_user(user)), 201
|
||||
|
||||
|
||||
@@ -222,13 +252,16 @@ async def login():
|
||||
# difference is a reliable oracle for which emails have accounts here.
|
||||
dummy_verify(password)
|
||||
_sign_in_failed(email)
|
||||
logger.warning("sign-in failed (no such account) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
if not verify_password(password, user.password_hash):
|
||||
_sign_in_failed(email)
|
||||
logger.warning("sign-in failed (bad password) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
_sign_in_succeeded(email)
|
||||
session[SESSION_KEY] = str(user.id)
|
||||
session.permanent = True
|
||||
logger.info("sign-in ok email=%s from=%s", email, client_address())
|
||||
return jsonify(_serialize_user(user))
|
||||
|
||||
|
||||
@@ -296,12 +329,19 @@ async def device_login():
|
||||
if user is None or not user.password_hash:
|
||||
dummy_verify(password)
|
||||
_sign_in_failed(email)
|
||||
logger.warning("device-login failed (no such account) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
if not verify_password(password, user.password_hash):
|
||||
_sign_in_failed(email)
|
||||
logger.warning("device-login failed (bad password) email=%s from=%s", email, client_address())
|
||||
return jsonify({"error": "invalid email or password"}), 401
|
||||
_sign_in_succeeded(email)
|
||||
row, token = await _issue_device_token(db, user.id, data.get("name") or "")
|
||||
# A device token outlives the session that made it, so its creation is the
|
||||
# most consequential thing on this blueprint.
|
||||
logger.info(
|
||||
"device token issued email=%s device=%s from=%s", email, row.name, client_address()
|
||||
)
|
||||
await db.commit()
|
||||
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
|
||||
|
||||
|
||||
@@ -48,3 +48,4 @@ class Config:
|
||||
def secret_key_env(cls) -> str | None:
|
||||
"""Optional break-glass override for the cookie-signing secret."""
|
||||
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None
|
||||
|
||||
|
||||
@@ -38,16 +38,15 @@ class Note(Base):
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
# The note's display NAME: explicit title if set, else the first non-empty body
|
||||
# line (see notes.derive_display_title). Persisted so every note — even a body-only
|
||||
# one — has something to be called in search results and in an export filename,
|
||||
# without forcing the user to type a title.
|
||||
# The note's NAME: its first non-empty body line, else its first checklist item
|
||||
# (see notes.derive_display_title). There is no title field to prefer — a note is
|
||||
# a body plus optional items, and this is simply the first thing written in it.
|
||||
# Persisted so search results and export filenames have something to say, and so
|
||||
# the full-text vector can weight it above the rest of the body.
|
||||
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).
|
||||
kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text")
|
||||
# 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())
|
||||
@@ -74,11 +73,9 @@ class Note(Base):
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"title": self.title,
|
||||
"display_title": self.display_title,
|
||||
"body": self.body,
|
||||
"color": self.color,
|
||||
"kind": self.kind,
|
||||
"position": self.position,
|
||||
"pinned": self.pinned,
|
||||
"archived": self.archived,
|
||||
|
||||
@@ -11,7 +11,11 @@ from . import Base
|
||||
|
||||
|
||||
class NoteItem(Base):
|
||||
"""A single checklist item within a note (only used when note.kind == 'list')."""
|
||||
"""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"
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ from . import Base
|
||||
|
||||
|
||||
class NoteRevision(Base):
|
||||
"""A point-in-time snapshot of a note's title+body, written on each edit that
|
||||
changes either — so an accidental overwrite can be viewed and restored. Only
|
||||
title+body are versioned in v1 (not items/attachments/labels)."""
|
||||
"""A point-in-time snapshot of a note's body, written on each edit that changes
|
||||
it — so an accidental overwrite can be viewed and restored. Only the body is
|
||||
versioned (not items/attachments/labels)."""
|
||||
|
||||
__tablename__ = "note_revisions"
|
||||
__table_args__ = (Index("ix_note_revisions_note_created", "note_id", "created_at"),)
|
||||
@@ -22,6 +22,5 @@ class NoteRevision(Base):
|
||||
note_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
@@ -13,7 +13,7 @@ from . import Base
|
||||
class SavedFilter(Base):
|
||||
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one
|
||||
click — e.g. "Yellow + #ideas". `params` is a JSON-encoded facet dict matching the
|
||||
GET /api/notes query (q/color/kind/labels/has_reminder/has_attachment/date range)."""
|
||||
GET /api/notes query (q/color/labels/has_reminder/has_attachment/date range)."""
|
||||
|
||||
__tablename__ = "saved_filters"
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from ..models.note_revision import NoteRevision
|
||||
from ..responses import json_error, not_found, parse_uuid
|
||||
from ..retention import purge_note
|
||||
from ..settings import get_setting
|
||||
from ..unfurl_queue import schedule as schedule_unfurls
|
||||
from ..unfurl import UnfurlError, unfurl
|
||||
from ._bp import bp
|
||||
from .helpers import (
|
||||
@@ -101,7 +102,6 @@ async def list_notes():
|
||||
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
|
||||
label_params = request.args.getlist("label")
|
||||
color = request.args.get("color")
|
||||
kind = request.args.get("kind")
|
||||
has_reminder = coerce_bool(request.args.get("has_reminder"))
|
||||
has_attachment = coerce_bool(request.args.get("has_attachment"))
|
||||
query_text = (request.args.get("q") or "").strip()
|
||||
@@ -125,10 +125,6 @@ async def list_notes():
|
||||
if color not in NOTE_COLORS:
|
||||
return json_error("invalid color", 400)
|
||||
stmt = stmt.where(Note.color == color)
|
||||
if kind is not None:
|
||||
if kind not in ("text", "list"):
|
||||
return json_error("invalid kind", 400)
|
||||
stmt = stmt.where(Note.kind == kind)
|
||||
if has_reminder:
|
||||
stmt = stmt.where(Note.remind_at.is_not(None))
|
||||
if has_attachment:
|
||||
@@ -144,8 +140,10 @@ async def list_notes():
|
||||
return json_error("invalid created_before", 400)
|
||||
stmt = stmt.where(Note.created_at < before_dt)
|
||||
if query_text:
|
||||
# Full-text match over title+body (generated tsvector, migration 0005),
|
||||
# ranked — so the facet bar's text box searches, not just filters.
|
||||
# Full-text match over the note's name + body (generated tsvector,
|
||||
# migrations 0005/0026), ranked. This is the ONLY text search now: the
|
||||
# separate facet-less `/search` route was removed because landing on it
|
||||
# was the one place you could not also narrow by tag (note 2930).
|
||||
tsquery = func.websearch_to_tsquery("english", query_text)
|
||||
search_col = literal_column("notes.search_vector")
|
||||
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
|
||||
@@ -159,30 +157,6 @@ async def list_notes():
|
||||
return jsonify({"notes": await _serialize_notes(db, notes)})
|
||||
|
||||
|
||||
@bp.get("/search")
|
||||
@login_required
|
||||
async def search_notes():
|
||||
q = (request.args.get("q") or "").strip()
|
||||
if not q:
|
||||
return jsonify({"notes": []})
|
||||
async with session_scope() as db:
|
||||
tsquery = func.websearch_to_tsquery("english", q)
|
||||
# search_vector is a generated column (migration 0005), not mapped on the ORM.
|
||||
search_col = literal_column("notes.search_vector")
|
||||
stmt = (
|
||||
select(Note)
|
||||
.where(
|
||||
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
|
||||
Note.deleted_at.is_(None),
|
||||
search_col.op("@@")(tsquery),
|
||||
)
|
||||
.order_by(func.ts_rank(search_col, tsquery).desc(), Note.updated_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
notes = (await db.scalars(stmt)).all()
|
||||
return jsonify({"notes": await _serialize_notes(db, notes)})
|
||||
|
||||
|
||||
@bp.get("/reminders")
|
||||
@login_required
|
||||
async def list_reminders():
|
||||
@@ -278,11 +252,9 @@ async def export_notes():
|
||||
payload["notes"].append(
|
||||
{
|
||||
"id": str(n.id),
|
||||
"title": n.title,
|
||||
"display_title": n.display_title,
|
||||
"body": n.body,
|
||||
"color": n.color,
|
||||
"kind": n.kind,
|
||||
"pinned": n.pinned,
|
||||
"archived": n.archived,
|
||||
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
|
||||
@@ -412,20 +384,32 @@ 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():
|
||||
data = await request.get_json(silent=True) or {}
|
||||
title = data.get("title") if isinstance(data.get("title"), str) else ""
|
||||
body = data.get("body") if isinstance(data.get("body"), str) else ""
|
||||
kind = data.get("kind") if data.get("kind") in ("text", "list") else "text"
|
||||
# A checklist note's "content" is its items, not the body — so it's non-empty
|
||||
# when it has a title or at least one item (quick-add can create one in one shot).
|
||||
item_texts = parse_list_items(data.get("items")) if kind == "list" else []
|
||||
if kind == "list":
|
||||
if not (title.strip() or item_texts):
|
||||
return json_error("note is empty", 400)
|
||||
elif is_empty_note(title, body):
|
||||
# Items are accepted on ANY note — a checklist is something a note HAS.
|
||||
item_texts = parse_list_items(data.get("items"))
|
||||
if is_empty_note(body, item_texts):
|
||||
return json_error("note is empty", 400)
|
||||
async with session_scope() as db:
|
||||
# New notes go to the top of the manual order.
|
||||
@@ -434,13 +418,10 @@ async def create_note():
|
||||
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
clean_title = title.strip() or None
|
||||
note = Note(
|
||||
owner_id=g.user_id,
|
||||
title=clean_title,
|
||||
display_title=derive_display_title(clean_title, body),
|
||||
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
|
||||
body=body,
|
||||
kind=kind,
|
||||
color=normalize_color(data.get("color")),
|
||||
position=int(max_pos) + 1,
|
||||
)
|
||||
@@ -451,6 +432,9 @@ async def create_note():
|
||||
await _reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
# After the commit, never before it: the note is saved and the response is
|
||||
# about to go out. Any link previews arrive on a later read.
|
||||
schedule_unfurls(note.id, note.body)
|
||||
return jsonify(await _serialize_note(db, note)), 201
|
||||
|
||||
|
||||
@@ -477,17 +461,11 @@ async def update_note(note_id: str):
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
old_title = note.title
|
||||
old_body = note.body
|
||||
if "title" in data:
|
||||
title = data["title"] if isinstance(data["title"], str) else ""
|
||||
note.title = title.strip() or None
|
||||
if "body" in data and isinstance(data["body"], str):
|
||||
note.body = data["body"]
|
||||
if "color" in data:
|
||||
note.color = normalize_color(data["color"])
|
||||
if "kind" in data and data["kind"] in ("text", "list"):
|
||||
note.kind = data["kind"]
|
||||
if "pinned" in data:
|
||||
note.pinned = bool(data["pinned"])
|
||||
if "archived" in data:
|
||||
@@ -504,24 +482,22 @@ async def update_note(note_id: str):
|
||||
note.remind_at = remind_dt
|
||||
if "recurrence" in data:
|
||||
note.recurrence = normalize_recurrence(data["recurrence"])
|
||||
# Recompute the display name (explicit title, else first body line) whenever
|
||||
# the title or body may have changed.
|
||||
if "title" in data or "body" in data:
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
if "body" in data:
|
||||
note.display_title = await _name_for(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
# Version history: snapshot the PRE-edit title+body whenever either changed.
|
||||
if note.title != old_title or note.body != old_body:
|
||||
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
||||
# Version history: snapshot the PRE-edit body whenever it changed.
|
||||
if note.body != old_body:
|
||||
db.add(NoteRevision(note_id=note.id, body=old_body))
|
||||
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))
|
||||
|
||||
|
||||
def _serialize_revision(rev: NoteRevision) -> dict:
|
||||
return {
|
||||
"id": str(rev.id),
|
||||
"title": rev.title,
|
||||
"body": rev.body,
|
||||
"created_at": iso(rev.created_at),
|
||||
}
|
||||
@@ -558,14 +534,13 @@ async def restore_revision(note_id: str, rev_id: str):
|
||||
rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
|
||||
if rev is None:
|
||||
return not_found()
|
||||
if note.title == rev.title and note.body == rev.body:
|
||||
if note.body == rev.body:
|
||||
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
||||
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
||||
# the revision — with the same title/body ripple as a normal edit.
|
||||
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
|
||||
note.title = rev.title
|
||||
# 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 = derive_display_title(note.title, note.body)
|
||||
note.display_title = await _name_for(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
|
||||
@@ -19,22 +19,30 @@ VALID_FILTERS = {"active", "archived", "trash"}
|
||||
DISPLAY_TITLE_CAP = 200
|
||||
|
||||
|
||||
def derive_display_title(title: str | None, body: str | None) -> str:
|
||||
"""The note's display NAME: the explicit title if set, else the first non-empty
|
||||
line of the body (trimmed, length-capped). Persisted as notes.display_title so a
|
||||
body-only note is still nameable/searchable/linkable — the user never has to type
|
||||
a title. Deterministic (literal first line, no AI)."""
|
||||
if title and title.strip():
|
||||
return title.strip()[:DISPLAY_TITLE_CAP]
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
The item fallback is what step 2 bought: a note that is only a checklist would
|
||||
otherwise have no name at all, which is exactly the hole that made removing the
|
||||
title unsafe before checklists stopped being their own kind of thing.
|
||||
|
||||
Deterministic — a literal first line, never generated.
|
||||
"""
|
||||
for line in (body or "").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped[:DISPLAY_TITLE_CAP]
|
||||
return ""
|
||||
return (first_item or "").strip()[:DISPLAY_TITLE_CAP]
|
||||
|
||||
|
||||
def is_empty_note(title: str | None, body: str | None) -> bool:
|
||||
return not (title or "").strip() and not (body or "").strip()
|
||||
def is_empty_note(body: str | None, items: list | None = None) -> bool:
|
||||
"""Nothing worth keeping: no body text and no checklist items."""
|
||||
return not (body or "").strip() and not items
|
||||
|
||||
|
||||
def parse_list_items(raw: object) -> list[str]:
|
||||
|
||||
@@ -36,8 +36,6 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
|
||||
"""One note as a human-readable Markdown file with a small frontmatter block.
|
||||
The authoritative machine format is notes.json; this is for reading/portability."""
|
||||
fm = ["---"]
|
||||
if note.title:
|
||||
fm.append(f"title: {note.title}")
|
||||
fm.append(f"display_name: {note.display_title}")
|
||||
if labels:
|
||||
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
|
||||
@@ -52,11 +50,15 @@ def _note_markdown(note: Note, labels: list, items: list) -> str:
|
||||
fm.append(f"updated: {note.updated_at.isoformat() if note.updated_at else ''}")
|
||||
fm.append("---")
|
||||
fm.append("")
|
||||
if note.kind == "list":
|
||||
# Body and checklist are no longer alternatives — a note can carry both, so both
|
||||
# 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']}")
|
||||
else:
|
||||
fm.append(note.body)
|
||||
return "\n".join(fm) + "\n"
|
||||
|
||||
|
||||
@@ -100,7 +102,6 @@ def _native_spec(n: dict) -> dict:
|
||||
return {
|
||||
"title": n.get("title"),
|
||||
"body": n.get("body") or "",
|
||||
"kind": n.get("kind"),
|
||||
"color": n.get("color"),
|
||||
"pinned": bool(n.get("pinned")),
|
||||
"archived": bool(n.get("archived")),
|
||||
@@ -128,8 +129,10 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
|
||||
"""Normalize one Google Keep note (Takeout <note>.json) into the common import
|
||||
spec. `keep_dir` is the note JSON's folder, used to resolve attachment paths."""
|
||||
list_content = kn.get("listContent") if isinstance(kn.get("listContent"), list) else []
|
||||
is_list = bool(list_content)
|
||||
body = kn.get("textContent") or "" if not is_list else ""
|
||||
# Keep's own notes are one or the other, but its text was being DISCARDED whenever
|
||||
# a note also had list content, because the target model could only hold one.
|
||||
# It can hold both now, so both are kept.
|
||||
body = kn.get("textContent") or ""
|
||||
# Keep stores link annotations (e.g. shared URLs) separately from the text —
|
||||
# fold any URLs into the body so the content survives the move.
|
||||
urls = [
|
||||
@@ -155,7 +158,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
|
||||
return {
|
||||
"title": kn.get("title"),
|
||||
"body": body,
|
||||
"kind": "list" if is_list else "text",
|
||||
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
|
||||
"pinned": bool(kn.get("isPinned")),
|
||||
"archived": bool(kn.get("isArchived")),
|
||||
@@ -272,24 +274,30 @@ async def _create_imported_note(
|
||||
db, owner_id, spec: dict, zf: zipfile.ZipFile, position: int, budget: _ImportBudget
|
||||
) -> bool:
|
||||
"""Insert one imported note plus its items/labels/attachments, reusing the same
|
||||
display-title derivation + tag/link reconciliation as create_note. Returns False
|
||||
(nothing written) when the spec is empty."""
|
||||
title = (spec.get("title") or "").strip() or None
|
||||
name derivation + tag reconciliation as create_note. Returns False (nothing
|
||||
written) when the spec is empty."""
|
||||
body = spec.get("body") or ""
|
||||
kind = spec.get("kind") if spec.get("kind") in ("text", "list") else "text"
|
||||
# An imported title becomes the note's FIRST BODY LINE.
|
||||
#
|
||||
# ThoughtSync has no title field any more (M13 step 3), but the things people
|
||||
# import from do — Keep notes carry one, and so does any export taken before this.
|
||||
# Dropping it would silently lose text someone wrote; folding it into the body puts
|
||||
# it exactly where a name now lives, so the note comes in named the way it was.
|
||||
# Skipped when the body already opens with that line, so re-importing an export
|
||||
# this code produced doesn't stack duplicates.
|
||||
title = (spec.get("title") or "").strip()
|
||||
if title and body.lstrip().split("\n", 1)[0].strip() != title:
|
||||
body = f"{title}\n{body}" if body.strip() else title
|
||||
|
||||
items = spec.get("items") or []
|
||||
if kind == "list":
|
||||
if not (title or any((it.get("text") or "").strip() for it in items)):
|
||||
return False
|
||||
elif is_empty_note(title, body):
|
||||
item_texts = [t for t in ((it.get("text") or "").strip() for it in items) if t]
|
||||
if is_empty_note(body, item_texts):
|
||||
return False
|
||||
|
||||
note = Note(
|
||||
owner_id=owner_id,
|
||||
title=title,
|
||||
display_title=derive_display_title(title, body),
|
||||
display_title=derive_display_title(body, item_texts[0] if item_texts else None),
|
||||
body=body,
|
||||
kind=kind,
|
||||
color=normalize_color(spec.get("color")),
|
||||
pinned=bool(spec.get("pinned")),
|
||||
archived=bool(spec.get("archived")),
|
||||
@@ -310,7 +318,6 @@ async def _create_imported_note(
|
||||
db.add(note)
|
||||
await db.flush() # assign note.id before items/labels/attachments/links
|
||||
|
||||
if kind == "list":
|
||||
for pos, it in enumerate(items):
|
||||
text = (it.get("text") or "").strip()
|
||||
if text:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Reading what the proxies in front of this app say about a request.
|
||||
|
||||
Two headers carry information the app cannot see for itself — who the client is
|
||||
(`X-Forwarded-For`) and whether they arrived over TLS (`X-Forwarded-Proto`) — and both
|
||||
are trusted by the same rule, so the rule lives in one place. Writing it twice is
|
||||
precisely how issue 2183 happened: two places holding one decision, and only one of
|
||||
them updated.
|
||||
|
||||
## The rule
|
||||
|
||||
A forwarding header grows LEFT to RIGHT. Each hop appends what IT saw, so the
|
||||
rightmost entries are the ones our own infrastructure wrote, and anything a caller
|
||||
sent arrives to the LEFT of those.
|
||||
|
||||
That inverts the intuitive reading. The leftmost entry is nominally "the original
|
||||
client" — and is exactly the one a caller can forge, by sending the header themselves.
|
||||
So we count in from the right by the number of proxies we actually run
|
||||
(the **Trusted proxy hops** setting, default 1), and a forged prefix can never be
|
||||
selected no matter how much of it there is.
|
||||
|
||||
Too HIGH a hop count is the dangerous direction: it starts believing entries no proxy
|
||||
of ours wrote. Too low just means several callers share a bucket. So when the header
|
||||
is shorter than configured — fewer proxies than expected — we fall back to the socket
|
||||
address rather than reaching further left.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import has_request_context, request
|
||||
|
||||
from .settings import live
|
||||
|
||||
|
||||
def trusted_entry(header: str, hops: int) -> str | None:
|
||||
"""The nth-from-the-right entry of a forwarding header, or None if there isn't one.
|
||||
|
||||
Pure, so the trust boundary is testable without a request context.
|
||||
"""
|
||||
if hops <= 0:
|
||||
return None
|
||||
entries = [part.strip() for part in header.split(",") if part.strip()]
|
||||
if len(entries) < hops:
|
||||
return None
|
||||
return entries[-hops]
|
||||
|
||||
|
||||
def forwarded_for(header: str, remote_addr: str | None, hops: int) -> str:
|
||||
"""The client address a proxy chain vouches for, else this connection's peer."""
|
||||
entry = trusted_entry(header, hops)
|
||||
return (entry or remote_addr or "unknown")[:64] # bounded: becomes a dict key
|
||||
|
||||
|
||||
def client_address() -> str:
|
||||
"""The caller's address, as far as the deployment's own proxies vouch for it."""
|
||||
return forwarded_for(
|
||||
request.headers.get("X-Forwarded-For", ""),
|
||||
request.remote_addr,
|
||||
live("trusted_proxy_hops"),
|
||||
)
|
||||
|
||||
|
||||
def is_https() -> bool:
|
||||
"""Whether this request reached us over TLS — directly, or via a trusted proxy.
|
||||
|
||||
Shared by the session cookie's `Secure` flag and by HSTS, because they are the same
|
||||
question. Read with the same hop count as the address: a caller who sets
|
||||
`X-Forwarded-Proto: https` on a plain-HTTP request puts it to the left of whatever
|
||||
our proxy appended, so it is not what gets read.
|
||||
"""
|
||||
if not has_request_context():
|
||||
return False
|
||||
if request.is_secure:
|
||||
return True
|
||||
entry = trusted_entry(request.headers.get("X-Forwarded-Proto", ""), live("trusted_proxy_hops"))
|
||||
return (entry or "").lower() == "https"
|
||||
@@ -22,9 +22,11 @@ came from, and either one can refuse it:
|
||||
is what stops credential stuffing against one known email, no matter how many
|
||||
addresses the attempts arrive from.
|
||||
- **The address** bounds the damage from one source spraying many accounts. It is
|
||||
best-effort by nature — behind a reverse proxy the client address is read from
|
||||
``X-Forwarded-For``, which a caller can set to anything if the app is exposed
|
||||
directly. That is precisely why it is not the only key.
|
||||
read from ``X-Forwarded-For``, counting in from the RIGHT by
|
||||
the **Trusted proxy hops** setting so that only entries our own proxies wrote are
|
||||
believed — a forged header lands to the left of those and is never selected. It is
|
||||
still the weaker of the two keys, because it depends on that setting matching the
|
||||
deployment; the account key depends on nothing.
|
||||
|
||||
Counting is by failure for the sign-in routes and by attempt for registration: a
|
||||
correct password should never move someone closer to being locked out, but every
|
||||
@@ -34,30 +36,18 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
|
||||
from quart import request
|
||||
from .settings import live
|
||||
|
||||
# Failed sign-ins tolerated per account before it stops answering, and for how long.
|
||||
# Ten is comfortably above a person mistyping a password and far below anything that
|
||||
# makes a dictionary worth running.
|
||||
ACCOUNT_LIMIT = 10
|
||||
ACCOUNT_WINDOW_S = 15 * 60
|
||||
|
||||
# Wider, because one address is legitimately many people: a household, an office
|
||||
# behind NAT, a phone on carrier-grade NAT.
|
||||
ADDRESS_LIMIT = 50
|
||||
ADDRESS_WINDOW_S = 15 * 60
|
||||
|
||||
# Registration is scarcer than a sign-in — it creates a row, and on a private
|
||||
# instance the honest number of accounts anyone needs to make is one.
|
||||
REGISTER_LIMIT = 5
|
||||
REGISTER_WINDOW_S = 60 * 60
|
||||
|
||||
# Never let the bookkeeping become the denial of service: an attacker rotating a
|
||||
# forged X-Forwarded-For could otherwise mint an unbounded number of buckets. Well
|
||||
# above any real deployment's distinct-caller count, so a legitimate instance never
|
||||
# reaches it; when it is reached the oldest buckets are dropped, which at worst
|
||||
# forgives some attempts.
|
||||
# Never let the bookkeeping become the denial of service: an attacker rotating an
|
||||
# address could otherwise mint an unbounded number of buckets. Well above any real
|
||||
# deployment's distinct-caller count, so a legitimate instance never reaches it; when
|
||||
# it is reached the oldest buckets are dropped, which at worst forgives some attempts.
|
||||
#
|
||||
# Not a setting: it protects the limiter from itself rather than the app from a
|
||||
# caller, and there is no operator judgment to apply to it.
|
||||
MAX_BUCKETS = 10_000
|
||||
|
||||
|
||||
@@ -67,13 +57,25 @@ class SlidingWindow:
|
||||
Sliding rather than a fixed window because a fixed one lets twice the limit
|
||||
through across a boundary — 10 at 14:59 and 10 at 15:00 — which for a login
|
||||
limiter is the difference between the number meaning something and not.
|
||||
|
||||
The limit and window are SUPPLIERS, not values, so an admin saving a new number in
|
||||
Settings takes effect on the next attempt instead of the next deploy. They are read
|
||||
per call, which is a dict lookup — the settings cache never touches the database.
|
||||
"""
|
||||
|
||||
def __init__(self, limit: int, window_s: float) -> None:
|
||||
self.limit = limit
|
||||
self.window_s = window_s
|
||||
def __init__(self, limit: Callable[[], int], window_s: Callable[[], float]) -> None:
|
||||
self._limit = limit
|
||||
self._window_s = window_s
|
||||
self._hits: dict[str, deque[float]] = {}
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
return self._limit()
|
||||
|
||||
@property
|
||||
def window_s(self) -> float:
|
||||
return self._window_s()
|
||||
|
||||
def _prune(self, key: str, now: float) -> deque[float]:
|
||||
hits = self._hits.get(key)
|
||||
if hits is None:
|
||||
@@ -111,27 +113,19 @@ class SlidingWindow:
|
||||
self._hits.clear()
|
||||
|
||||
|
||||
sign_in_by_account = SlidingWindow(ACCOUNT_LIMIT, ACCOUNT_WINDOW_S)
|
||||
sign_in_by_address = SlidingWindow(ADDRESS_LIMIT, ADDRESS_WINDOW_S)
|
||||
register_by_address = SlidingWindow(REGISTER_LIMIT, REGISTER_WINDOW_S)
|
||||
def _minutes(key: str) -> Callable[[], float]:
|
||||
return lambda: float(live(key)) * 60.0
|
||||
|
||||
|
||||
def client_address() -> str:
|
||||
"""The caller's address, as well as it can be known.
|
||||
|
||||
``X-Forwarded-For`` is a list appended to by each hop, so the leftmost entry is
|
||||
the original client — and also the only entry a client can choose for itself.
|
||||
It is trusted here anyway, because the alternative behind a reverse proxy is to
|
||||
see the proxy's address for every request on earth and rate-limit the entire
|
||||
internet as one caller. The account-keyed limit is the one that holds when this
|
||||
one is lied to.
|
||||
"""
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
first = forwarded.split(",")[0].strip()
|
||||
if first:
|
||||
return first[:64] # bounded: this becomes a dict key
|
||||
return (request.remote_addr or "unknown")[:64]
|
||||
sign_in_by_account = SlidingWindow(
|
||||
lambda: live("signin_limit_per_account"), _minutes("signin_window_minutes")
|
||||
)
|
||||
sign_in_by_address = SlidingWindow(
|
||||
lambda: live("signin_limit_per_address"), _minutes("signin_window_minutes")
|
||||
)
|
||||
register_by_address = SlidingWindow(
|
||||
lambda: live("register_limit_per_address"), _minutes("register_window_minutes")
|
||||
)
|
||||
|
||||
|
||||
def reset_all() -> None:
|
||||
|
||||
@@ -89,7 +89,6 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
|
||||
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
||||
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
||||
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||
note.title = None
|
||||
note.body = ""
|
||||
note.display_title = ""
|
||||
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
|
||||
|
||||
@@ -19,7 +19,6 @@ NAME_CAP = 100
|
||||
_ALLOWED_PARAM_KEYS = {
|
||||
"q",
|
||||
"color",
|
||||
"kind",
|
||||
"label", # matches the repeatable ?label= query param (stored as an array)
|
||||
"has_reminder",
|
||||
"has_attachment",
|
||||
|
||||
+133
-2
@@ -19,6 +19,13 @@ class SettingDef:
|
||||
label: str
|
||||
description: str
|
||||
group: str
|
||||
# Ints only. Enforced server-side in validate_updates and passed to the UI so the
|
||||
# number input carries them too. These exist because several of the security
|
||||
# values have ranges where a typo is not merely wrong but dangerous — a proxy hop
|
||||
# count of 50 would trust anything a caller sent, and a sign-in limit of 0 would
|
||||
# lock every account out permanently.
|
||||
minimum: int | None = None
|
||||
maximum: int | None = None
|
||||
|
||||
|
||||
# The source of truth for every user-facing setting. Add a row here and it appears
|
||||
@@ -32,7 +39,8 @@ REGISTRY: list[SettingDef] = [
|
||||
"bool",
|
||||
True,
|
||||
"Allow new registrations",
|
||||
"When off, only existing users can sign in. The first account is always allowed.",
|
||||
"When off, only existing users can sign in. Closes itself once the first "
|
||||
"account exists — turn it back on only while you're adding someone.",
|
||||
"Access",
|
||||
),
|
||||
SettingDef(
|
||||
@@ -69,6 +77,80 @@ REGISTRY: list[SettingDef] = [
|
||||
"The server contacts the linked site; private/internal addresses are always blocked.",
|
||||
"Links",
|
||||
),
|
||||
# --- Security -----------------------------------------------------------------
|
||||
#
|
||||
# Read on paths too hot for a database round trip (the credential throttle checks
|
||||
# them BEFORE opening a connection, which is the point of checking a throttle
|
||||
# before doing expensive work), so they are cached — see `live()` below.
|
||||
SettingDef(
|
||||
"trusted_proxy_hops",
|
||||
"int",
|
||||
1,
|
||||
"Trusted proxy hops",
|
||||
"How many proxies sit in front of this server. 1 for a single reverse proxy "
|
||||
"terminating HTTPS; 2 if a CDN like Cloudflare sits in front of that; 0 if "
|
||||
"the app is exposed directly. This decides which entry of X-Forwarded-For is "
|
||||
"believed — set it TOO HIGH and a visitor can forge their own address and "
|
||||
"slip the sign-in limits below.",
|
||||
"Security",
|
||||
minimum=0,
|
||||
maximum=10,
|
||||
),
|
||||
SettingDef(
|
||||
"signin_limit_per_account",
|
||||
"int",
|
||||
10,
|
||||
"Failed sign-ins per account",
|
||||
"How many failures one account tolerates within the window before it stops "
|
||||
"answering. Comfortably above mistyping a password, far below anything that "
|
||||
"makes guessing worth attempting.",
|
||||
"Security",
|
||||
minimum=1,
|
||||
maximum=1000,
|
||||
),
|
||||
SettingDef(
|
||||
"signin_limit_per_address",
|
||||
"int",
|
||||
50,
|
||||
"Failed sign-ins per address",
|
||||
"The same, counted per visitor address instead of per account — it bounds one "
|
||||
"source trying many accounts. Wider, because one address is legitimately many "
|
||||
"people: a household, an office, a phone on carrier NAT.",
|
||||
"Security",
|
||||
minimum=1,
|
||||
maximum=10000,
|
||||
),
|
||||
SettingDef(
|
||||
"signin_window_minutes",
|
||||
"int",
|
||||
15,
|
||||
"Sign-in window (minutes)",
|
||||
"The trailing period both sign-in limits are counted over.",
|
||||
"Security",
|
||||
minimum=1,
|
||||
maximum=1440,
|
||||
),
|
||||
SettingDef(
|
||||
"register_limit_per_address",
|
||||
"int",
|
||||
5,
|
||||
"Sign-ups per address",
|
||||
"How many accounts one address may create within its window. Counted per "
|
||||
"attempt rather than per failure — each one is a row either way.",
|
||||
"Security",
|
||||
minimum=1,
|
||||
maximum=1000,
|
||||
),
|
||||
SettingDef(
|
||||
"register_window_minutes",
|
||||
"int",
|
||||
60,
|
||||
"Sign-up window (minutes)",
|
||||
"The trailing period the sign-up limit is counted over.",
|
||||
"Security",
|
||||
minimum=1,
|
||||
maximum=10080,
|
||||
),
|
||||
]
|
||||
|
||||
_BY_KEY: dict[str, SettingDef] = {d.key: d for d in REGISTRY}
|
||||
@@ -152,11 +234,52 @@ async def get_admin_settings(db) -> list[dict]:
|
||||
"label": d.label,
|
||||
"description": d.description,
|
||||
"group": d.group,
|
||||
"minimum": d.minimum,
|
||||
"maximum": d.maximum,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# Settings the app must be able to read WITHOUT awaiting a database.
|
||||
#
|
||||
# The credential throttle consults these before opening a connection — deliberately,
|
||||
# because a refused attempt is supposed to cost nothing, and the proxy hop count is
|
||||
# needed to know who is even asking. A per-request query would undo both.
|
||||
#
|
||||
# Seeded from the registry defaults so the app works before (and without) a database —
|
||||
# unit tests construct it with no Postgres at all — then refreshed from the DB at boot
|
||||
# and again whenever an admin saves. Same live-update contract `session_ttl_days`
|
||||
# already has in settings_api.py.
|
||||
_LIVE_KEYS = (
|
||||
"trusted_proxy_hops",
|
||||
"signin_limit_per_account",
|
||||
"signin_limit_per_address",
|
||||
"signin_window_minutes",
|
||||
"register_limit_per_address",
|
||||
"register_window_minutes",
|
||||
)
|
||||
|
||||
_live: dict[str, Any] = {k: _BY_KEY[k].default for k in _LIVE_KEYS}
|
||||
|
||||
|
||||
def live(key: str) -> Any:
|
||||
"""The cached value of a hot setting. Synchronous, never touches the database."""
|
||||
return _live[key]
|
||||
|
||||
|
||||
async def refresh_live(db) -> None:
|
||||
"""Re-read the hot settings into the cache. Called at boot and after every save."""
|
||||
for key in _LIVE_KEYS:
|
||||
_live[key] = await get_setting(db, key)
|
||||
|
||||
|
||||
def reset_live() -> None:
|
||||
"""Back to registry defaults. For tests — nothing in the app calls this."""
|
||||
for key in _LIVE_KEYS:
|
||||
_live[key] = _BY_KEY[key].default
|
||||
|
||||
|
||||
def validate_updates(updates: dict) -> tuple[dict, str | None]:
|
||||
"""Coerce/validate a {key: value} dict against the registry. Returns
|
||||
(clean_values, error_message). An unknown key or a bad int is rejected."""
|
||||
@@ -167,9 +290,17 @@ def validate_updates(updates: dict) -> tuple[dict, str | None]:
|
||||
return {}, f"unknown setting: {key}"
|
||||
if defn.type == "int":
|
||||
try:
|
||||
clean[key] = int(val)
|
||||
n = int(val)
|
||||
except (ValueError, TypeError):
|
||||
return {}, f"{defn.label} must be a whole number"
|
||||
# Rejected rather than clamped: silently accepting a number and storing a
|
||||
# different one is how somebody ends up believing a protection is set to
|
||||
# something it is not.
|
||||
if defn.minimum is not None and n < defn.minimum:
|
||||
return {}, f"{defn.label} must be at least {defn.minimum}"
|
||||
if defn.maximum is not None and n > defn.maximum:
|
||||
return {}, f"{defn.label} must be at most {defn.maximum}"
|
||||
clean[key] = n
|
||||
elif defn.type == "bool":
|
||||
clean[key] = _coerce_bool(val)
|
||||
else:
|
||||
|
||||
@@ -6,7 +6,7 @@ from quart import Blueprint, current_app, jsonify, request
|
||||
|
||||
from .auth import require_admin
|
||||
from .db import session_scope
|
||||
from .settings import get_admin_settings, set_settings, validate_updates
|
||||
from .settings import get_admin_settings, refresh_live, set_settings, validate_updates
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
|
||||
@@ -35,6 +35,10 @@ async def update_settings():
|
||||
async with session_scope() as db:
|
||||
await set_settings(db, clean)
|
||||
await db.commit()
|
||||
# Re-read the cached security values so a saved limit or hop count applies to
|
||||
# the very next request. Unconditional: cheap, and a conditional here would be
|
||||
# one more place that has to know which keys are hot.
|
||||
await refresh_live(db)
|
||||
result = await get_admin_settings(db)
|
||||
|
||||
# Apply the live-tunable knob without a restart (rule 25).
|
||||
|
||||
+52
-15
@@ -35,6 +35,7 @@ from .notes import (
|
||||
)
|
||||
from .retention import purge_note
|
||||
from .serialize import serialize_label_sync
|
||||
from .unfurl_queue import schedule as schedule_unfurls
|
||||
|
||||
bp = Blueprint("sync", __name__, url_prefix="/api/sync")
|
||||
|
||||
@@ -55,8 +56,14 @@ MAX_PUSH = 1000 # per-batch change cap
|
||||
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise
|
||||
# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch
|
||||
# that hard-blocks older clients, so additive changes must leave it alone.
|
||||
SYNC_PROTOCOL_VERSION = 1
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 1
|
||||
# v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends
|
||||
# and expects back is breaking, so the FLOOR moves too — a v1 client would keep pushing
|
||||
# both and would read back notes carrying neither.
|
||||
#
|
||||
# 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
|
||||
|
||||
# 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
|
||||
@@ -190,11 +197,8 @@ def client_wins(client_edited_at: datetime | None, server_edited_at: datetime |
|
||||
def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
|
||||
whole-note, not a partial patch — the client sends its authoritative version)."""
|
||||
title = ch.get("title")
|
||||
note.title = (title or "").strip() or None if isinstance(title, str) else None
|
||||
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
|
||||
note.color = normalize_color(ch.get("color"))
|
||||
note.kind = ch["kind"] if ch.get("kind") in ("text", "list") else "text"
|
||||
note.pinned = bool(ch.get("pinned"))
|
||||
note.archived = bool(ch.get("archived"))
|
||||
if ch.get("trashed"):
|
||||
@@ -208,13 +212,41 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
|
||||
note.position = ch["position"]
|
||||
|
||||
|
||||
async def _apply_note_items(db, note: Note, ch: dict) -> None:
|
||||
"""Replace the note's checklist items with the client's (items sync inline)."""
|
||||
if note.kind != "list":
|
||||
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
|
||||
return
|
||||
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):
|
||||
@@ -280,20 +312,25 @@ async def _apply_note(db, ch: dict) -> dict:
|
||||
elif note.purged_at is not None:
|
||||
note.purged_at = None # client re-created/edited → clear the tombstone
|
||||
|
||||
old_title, old_body = note.title, note.body
|
||||
old_body = note.body
|
||||
_assign_note_fields(note, ch)
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
note.display_title = derive_display_title(note.body, _first_item_text(ch))
|
||||
if edited_at is not None:
|
||||
note.updated_at = edited_at
|
||||
# Non-destructive LWW: snapshot the overwritten server title+body into history.
|
||||
if not creating and (note.title != old_title or note.body != old_body):
|
||||
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
||||
# Non-destructive LWW: snapshot the overwritten server body into history.
|
||||
if not creating and note.body != old_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()
|
||||
await db.refresh(note, ["sync_revision"])
|
||||
# A note pushed from a linked client gets the same link previews as one typed into
|
||||
# the web app — the client picks them up on its next pull. Scheduled rather than
|
||||
# awaited: a push batch must not wait on somebody else's website.
|
||||
if creating or note.body != old_body:
|
||||
schedule_unfurls(note.id, note.body)
|
||||
return {
|
||||
"id": str(nid),
|
||||
"entity": "note",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Unfurling a note's URLs in the background, after the note is already saved.
|
||||
|
||||
## Why this is not done inline
|
||||
|
||||
Capture speed is the product. Unfurling is a 5-second-timeout network call to a host
|
||||
nobody controls, and a note must persist the instant someone stops typing — so the
|
||||
save returns first and the preview catches up. A person who pastes a link and closes
|
||||
the composer has already done the thing they came to do.
|
||||
|
||||
## Why it is on the server rather than in each client
|
||||
|
||||
The server sees every note that reaches it, from all three surfaces, so detection and
|
||||
fetching live in one place instead of three. A linked desktop or Android client pushes
|
||||
its note and picks the preview up on the next pull; an unlinked one has no server to
|
||||
ask and simply has no preview until it links, which is the honest consequence of being
|
||||
offline rather than a gap to paper over.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
Fail loudly. A preview that could not be fetched is not an error the person needs —
|
||||
the note is fine, it just has no card. The link is still in the body, still clickable,
|
||||
still searchable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from .db import session_scope
|
||||
from .models.note import Note
|
||||
from .models.note_link_preview import NoteLinkPreview
|
||||
from .settings import get_setting
|
||||
from .unfurl import UnfurlError, unfurl
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches the frontend's detector (NoteEditor.vue) so both surfaces agree on what
|
||||
# counts as a link. Trailing sentence punctuation is stripped below rather than in the
|
||||
# pattern — a URL can legitimately end in most of these characters, just not when the
|
||||
# sentence does.
|
||||
_URL_RE = re.compile(r"(https?://[^\s<>\"'\])]+)")
|
||||
|
||||
# Per note, per save. A body pasted full of links should not turn into a burst of
|
||||
# outbound requests; nobody is reading forty preview cards on one card anyway.
|
||||
MAX_URLS_PER_NOTE = 5
|
||||
|
||||
# Background tasks are only weakly referenced by the event loop, so without a strong
|
||||
# reference here a task can be garbage-collected mid-flight. Discarded on completion.
|
||||
_running: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def detect_urls(body: str | None) -> list[str]:
|
||||
"""Distinct http(s) URLs in a note body, in order, trailing punctuation trimmed."""
|
||||
out: list[str] = []
|
||||
for match in _URL_RE.finditer(body or ""):
|
||||
url = match.group(1).rstrip(".,;:!?")
|
||||
if url and url not in out:
|
||||
out.append(url)
|
||||
return out
|
||||
|
||||
|
||||
async def _fetch_and_store(note_id: uuid.UUID, url: str) -> None:
|
||||
"""Unfurl one URL and cache it against the note. Silent on every failure."""
|
||||
try:
|
||||
preview = await unfurl(url)
|
||||
except UnfurlError as e:
|
||||
# Expected and uninteresting: a dead link, a private address, a non-page.
|
||||
logger.debug("no preview for %s: %s", url, e)
|
||||
return
|
||||
except Exception:
|
||||
logger.warning("unexpected failure unfurling %s", url, exc_info=True)
|
||||
return
|
||||
|
||||
async with session_scope() as db:
|
||||
# The note may have been deleted or the URL removed while the fetch was in
|
||||
# flight, so re-check rather than assuming the world held still.
|
||||
note = await db.scalar(select(Note).where(Note.id == note_id, Note.deleted_at.is_(None)))
|
||||
if note is None or url not in detect_urls(note.body):
|
||||
return
|
||||
row = await db.scalar(
|
||||
select(NoteLinkPreview).where(NoteLinkPreview.note_id == note_id, NoteLinkPreview.url == url)
|
||||
)
|
||||
if row is None:
|
||||
row = NoteLinkPreview(note_id=note_id, url=url)
|
||||
db.add(row)
|
||||
row.title = preview["title"]
|
||||
row.description = preview["description"]
|
||||
row.image_url = preview["image_url"]
|
||||
row.site_name = preview["site_name"]
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _unfurl_new_urls(note_id: uuid.UUID, body: str) -> None:
|
||||
async with session_scope() as db:
|
||||
if not await get_setting(db, "enable_url_unfurl"):
|
||||
return
|
||||
cached = set(
|
||||
(
|
||||
await db.scalars(select(NoteLinkPreview.url).where(NoteLinkPreview.note_id == note_id))
|
||||
).all()
|
||||
)
|
||||
fresh = [u for u in detect_urls(body) if u not in cached][:MAX_URLS_PER_NOTE]
|
||||
for url in fresh:
|
||||
await _fetch_and_store(note_id, url)
|
||||
|
||||
|
||||
def schedule(note_id: uuid.UUID, body: str | None) -> None:
|
||||
"""Queue an unfurl pass for a note that was just written. Returns immediately.
|
||||
|
||||
Safe to call on every save: it re-reads what is already cached and does nothing
|
||||
when there is nothing new, so an edit that doesn't touch the links costs one
|
||||
cheap query on a background task rather than a fetch.
|
||||
"""
|
||||
if not body or not detect_urls(body):
|
||||
return
|
||||
try:
|
||||
task = asyncio.create_task(_unfurl_new_urls(note_id, body))
|
||||
except RuntimeError:
|
||||
# No running loop — a script or a test calling the write path directly. The
|
||||
# note is saved either way; only the preview is skipped.
|
||||
return
|
||||
_running.add(task)
|
||||
task.add_done_callback(_running.discard)
|
||||
@@ -0,0 +1,416 @@
|
||||
"""The real-Postgres lane (family rule 6).
|
||||
|
||||
Everything else in this suite is deliberately DB-free, which means the schema the
|
||||
migrations build has never been checked against the models that read it. That gap is
|
||||
what this file closes, and it is not theoretical: M13 dropped three columns and
|
||||
rebuilt a generated column, and until now `alembic upgrade head` ran for the first
|
||||
time when the operator's container started.
|
||||
|
||||
Marked `integration` and excluded from the unit lane by `-m "not integration"`, so a
|
||||
workstation without Postgres runs the rest of the suite unchanged.
|
||||
|
||||
The schema comes from real migrations, never `metadata.create_all` (rule 82) — the
|
||||
point is to test what actually ships, and `create_all` would build a schema no
|
||||
deployment has ever seen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select, text
|
||||
|
||||
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.helpers import derive_display_title
|
||||
from thoughtsync.models.note_link_preview import NoteLinkPreview
|
||||
from thoughtsync.sync import _apply_note_items
|
||||
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"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db():
|
||||
"""A session against the migrated database, wiped before each test.
|
||||
|
||||
Wiped BEFORE rather than after so a failed test leaves its rows behind to look at.
|
||||
"""
|
||||
async with session_scope() as session:
|
||||
await session.execute(text(f"TRUNCATE {_TABLES} RESTART IDENTITY CASCADE"))
|
||||
await session.commit()
|
||||
yield session
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def app_client(db):
|
||||
"""A test client against the real app, over the migrated database.
|
||||
|
||||
The credential throttle is process-global and its counters outlive a single
|
||||
test, so they are cleared here — otherwise a suite that registers a few times
|
||||
starts handing out 429s for reasons that have nothing to do with the test.
|
||||
"""
|
||||
ratelimit.reset_all()
|
||||
yield create_app().test_client()
|
||||
ratelimit.reset_all()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def owner(db):
|
||||
"""A user to hang notes off — `notes.owner_id` is a real foreign key."""
|
||||
user = User(email=f"{uuid.uuid4().hex}@example.test", display_name="Integration")
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def test_the_migrated_schema_matches_the_models(db, owner):
|
||||
"""The check that has never run: insert through the ORM, read it back.
|
||||
|
||||
A column the models expect and the migrations never created — or the reverse —
|
||||
fails right here, instead of when a container starts.
|
||||
"""
|
||||
note = Note(owner_id=owner.id, body="a thought", display_title="a thought")
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
|
||||
found = await db.scalar(select(Note).where(Note.id == note.id))
|
||||
assert found is not None
|
||||
assert found.body == "a thought"
|
||||
assert found.display_title == "a thought"
|
||||
|
||||
|
||||
async def test_the_dropped_columns_are_actually_gone(db):
|
||||
"""M13 dropped three. If a migration silently no-opped, this is where it shows."""
|
||||
cols = set(
|
||||
(
|
||||
await db.execute(
|
||||
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'notes'")
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert "title" not in cols, "notes.title should have gone in 0026"
|
||||
assert "kind" not in cols, "notes.kind should have gone in 0025"
|
||||
assert "display_title" in cols and "body" in cols
|
||||
|
||||
rev_cols = set(
|
||||
(
|
||||
await db.execute(
|
||||
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'note_revisions'")
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert "title" not in rev_cols, "note_revisions.title should have gone in 0026"
|
||||
|
||||
tables = set(
|
||||
(await db.execute(text("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert "note_links" not in tables, "note_links should have gone in 0024"
|
||||
|
||||
|
||||
async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
|
||||
"""0026 had to drop and recreate a STORED GENERATED column.
|
||||
|
||||
Postgres refuses to drop a column another generated column depends on, so getting
|
||||
this wrong doesn't produce a subtly wrong ranking — it produces a migration that
|
||||
won't run at all. Worth proving the replacement actually indexes something.
|
||||
"""
|
||||
note = Note(owner_id=owner.id, body="ferry tickets\nbook before friday", display_title="ferry tickets")
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
|
||||
hit = await db.scalar(
|
||||
text(
|
||||
"SELECT count(*) FROM notes "
|
||||
"WHERE search_vector @@ websearch_to_tsquery('english', :q)"
|
||||
).bindparams(q="ferry")
|
||||
)
|
||||
assert hit == 1
|
||||
|
||||
# The NAME is weight A and the body weight B, which is what makes a name match
|
||||
# rank above a body-only one. Both must be in the vector at all.
|
||||
body_only = await db.scalar(
|
||||
text(
|
||||
"SELECT count(*) FROM notes "
|
||||
"WHERE search_vector @@ websearch_to_tsquery('english', :q)"
|
||||
).bindparams(q="friday")
|
||||
)
|
||||
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()
|
||||
|
||||
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.
|
||||
"""
|
||||
note = Note(owner_id=owner.id, body="packing", display_title="packing")
|
||||
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)]
|
||||
|
||||
|
||||
async def test_a_note_with_only_items_still_has_a_name(db, owner):
|
||||
"""The hole that made removing the title unsafe until step 2 closed it."""
|
||||
note = Note(owner_id=owner.id, body="", display_title="")
|
||||
db.add(note)
|
||||
await db.flush()
|
||||
db.add(NoteItem(note_id=note.id, text="milk", position=0))
|
||||
await db.commit()
|
||||
|
||||
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)
|
||||
await db.commit()
|
||||
|
||||
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
|
||||
|
||||
|
||||
async def test_auto_unfurl_stores_a_preview_and_skips_what_is_cached(db, owner, monkeypatch):
|
||||
"""The background pass, run inline so the assertions are deterministic.
|
||||
|
||||
The network is stubbed — this is about what reaches the DATABASE, not about
|
||||
parsing someone's OpenGraph tags (unfurl.py's own tests cover that). What matters
|
||||
here is the part only a real database can show: the unique constraint holding, the
|
||||
upsert going to the right row, and a second pass not re-fetching.
|
||||
"""
|
||||
note = Note(
|
||||
owner_id=owner.id,
|
||||
body="read https://example.com/a and https://example.com/b",
|
||||
display_title="read https://example.com/a and https://example.com/b",
|
||||
)
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_unfurl(url):
|
||||
calls.append(url)
|
||||
return {"url": url, "title": f"T {url}", "description": None, "image_url": None, "site_name": "example.com"}
|
||||
|
||||
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
||||
|
||||
await _unfurl_new_urls(note.id, note.body)
|
||||
assert sorted(calls) == ["https://example.com/a", "https://example.com/b"]
|
||||
|
||||
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
|
||||
assert {r.url for r in rows} == {"https://example.com/a", "https://example.com/b"}
|
||||
assert all(r.title.startswith("T ") for r in rows)
|
||||
|
||||
# A second pass over an unchanged body fetches nothing — the whole reason
|
||||
# `schedule` is safe to call on every save.
|
||||
calls.clear()
|
||||
await _unfurl_new_urls(note.id, note.body)
|
||||
assert calls == []
|
||||
|
||||
|
||||
async def test_auto_unfurl_drops_a_preview_whose_url_left_the_body(db, owner, monkeypatch):
|
||||
"""A slow fetch must not resurrect a link the person deleted mid-flight."""
|
||||
note = Note(owner_id=owner.id, body="https://example.com/gone", display_title="x")
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
|
||||
async def fake_unfurl(url):
|
||||
# Simulate the body changing while the request was in the air.
|
||||
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
|
||||
|
||||
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
||||
note.body = "changed my mind"
|
||||
await db.commit()
|
||||
|
||||
await _unfurl_new_urls(note.id, "https://example.com/gone")
|
||||
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
|
||||
assert rows == [], "a preview was stored for a URL the note no longer contains"
|
||||
|
||||
|
||||
async def test_detection_agrees_with_what_gets_stored(db, owner, monkeypatch):
|
||||
"""The detector and the storage path read the same body the same way."""
|
||||
body = "one https://example.com/x. two (https://example.com/y) three"
|
||||
assert detect_urls(body) == ["https://example.com/x", "https://example.com/y"]
|
||||
|
||||
note = Note(owner_id=owner.id, body=body, display_title="one")
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
|
||||
async def fake_unfurl(url):
|
||||
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
|
||||
|
||||
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
|
||||
await _unfurl_new_urls(note.id, body)
|
||||
|
||||
stored = {
|
||||
r for r in (await db.scalars(select(NoteLinkPreview.url).where(NoteLinkPreview.note_id == note.id))).all()
|
||||
}
|
||||
assert stored == set(detect_urls(body))
|
||||
|
||||
|
||||
async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
|
||||
"""The gap this removes: registration was open between "my account exists" and
|
||||
"I remembered to turn it off", and on a public host that gap starts at DNS.
|
||||
|
||||
Runs against a real database because it is the interaction between two writes —
|
||||
the user row and the settings row — inside one transaction.
|
||||
"""
|
||||
# The instance is empty (the fixture truncated it), so this is the first account:
|
||||
# allowed unconditionally, and it becomes the admin.
|
||||
first = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "owner@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
assert (await first.get_json())["is_admin"] is True
|
||||
|
||||
# …and the door shut behind it.
|
||||
async with session_scope() as fresh:
|
||||
assert await get_setting(fresh, "allow_registration") is False
|
||||
|
||||
second = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "stranger@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert second.status_code == 403
|
||||
|
||||
# Re-opening it deliberately still works — that is how a second person gets in
|
||||
# until invites exist.
|
||||
async with session_scope() as fresh:
|
||||
await set_settings(fresh, {"allow_registration": True})
|
||||
await fresh.commit()
|
||||
|
||||
third = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "invited@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert third.status_code == 201
|
||||
assert (await third.get_json())["is_admin"] is False
|
||||
|
||||
|
||||
async def test_security_settings_are_live_and_bounded(app_client, db):
|
||||
"""The security values are settings now, not constants — so saving one has to take
|
||||
effect without a restart, and a dangerous value has to be refused.
|
||||
|
||||
Real database because the whole point is the round trip: write through the admin
|
||||
API, re-read into the cache the throttle consults, observe the new number.
|
||||
"""
|
||||
# An admin to authenticate as. First account, so it is allowed and becomes admin.
|
||||
reset_live()
|
||||
created = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "admin@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
# Defaults are what the registry says.
|
||||
async with session_scope() as fresh:
|
||||
await refresh_live(fresh)
|
||||
assert live("trusted_proxy_hops") == 1
|
||||
assert live("signin_limit_per_account") == 10
|
||||
|
||||
# A value that would disable the protection is REFUSED, not clamped — storing a
|
||||
# different number than the one typed is how somebody ends up believing a limit
|
||||
# is set to something it is not.
|
||||
bad = await app_client.patch("/api/settings", json={"signin_limit_per_account": 0})
|
||||
assert bad.status_code == 400
|
||||
assert "at least" in (await bad.get_json())["error"]
|
||||
|
||||
# …and so is a hop count that would trust anything a caller sent.
|
||||
bad_hops = await app_client.patch("/api/settings", json={"trusted_proxy_hops": 99})
|
||||
assert bad_hops.status_code == 400
|
||||
|
||||
# A legitimate change applies to the cache the throttle reads, immediately.
|
||||
ok = await app_client.patch(
|
||||
"/api/settings", json={"signin_limit_per_account": 3, "trusted_proxy_hops": 2}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert live("signin_limit_per_account") == 3
|
||||
assert live("trusted_proxy_hops") == 2
|
||||
|
||||
# And it is persisted, not just cached.
|
||||
async with session_scope() as fresh:
|
||||
assert await get_setting(fresh, "trusted_proxy_hops") == 2
|
||||
|
||||
reset_live()
|
||||
|
||||
|
||||
async def test_the_security_group_reaches_the_admin_ui(app_client, db):
|
||||
"""Every security value has to be visible and editable, which is the whole reason
|
||||
they moved out of the environment."""
|
||||
created = await app_client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "admin2@example.test", "password": "a-long-enough-password"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
resp = await app_client.get("/api/settings")
|
||||
assert resp.status_code == 200
|
||||
rows = (await resp.get_json())["settings"]
|
||||
security = {r["key"]: r for r in rows if r["group"] == "Security"}
|
||||
|
||||
assert set(security) == {
|
||||
"trusted_proxy_hops",
|
||||
"signin_limit_per_account",
|
||||
"signin_limit_per_address",
|
||||
"signin_window_minutes",
|
||||
"register_limit_per_address",
|
||||
"register_window_minutes",
|
||||
}
|
||||
# The UI renders a number input from these, and it cannot offer a safe range it
|
||||
# was never told about.
|
||||
for row in security.values():
|
||||
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"
|
||||
+55
-26
@@ -5,6 +5,7 @@ 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.unfurl_queue import detect_urls
|
||||
from thoughtsync.notes import (
|
||||
_attachment_ext,
|
||||
_header_filename,
|
||||
@@ -36,7 +37,7 @@ def test_all_note_routes_registered(app):
|
||||
expected = {
|
||||
f"notes.{name}"
|
||||
for name in (
|
||||
"list_notes", "search_notes", "list_reminders", "complete_reminder",
|
||||
"list_notes", "list_reminders", "complete_reminder",
|
||||
"snooze_reminder", "export_notes", "import_notes", "list_titles",
|
||||
"reorder_notes", "create_note",
|
||||
"get_note", "update_note", "list_revisions", "restore_revision",
|
||||
@@ -51,9 +52,10 @@ def test_all_note_routes_registered(app):
|
||||
|
||||
def test_is_empty_note():
|
||||
assert is_empty_note(None, None)
|
||||
assert is_empty_note("", " ")
|
||||
assert not is_empty_note("title", "")
|
||||
assert not is_empty_note("", "body")
|
||||
assert is_empty_note(" ", [])
|
||||
assert not is_empty_note("body")
|
||||
# A note that is only a checklist is not empty — it just has nothing in its body.
|
||||
assert not is_empty_note("", ["milk"])
|
||||
|
||||
|
||||
def test_normalize_color():
|
||||
@@ -69,9 +71,9 @@ def test_palette_has_core_colors():
|
||||
|
||||
|
||||
def test_serialize_shape():
|
||||
n = Note(title="t", body="b", color="blue", pinned=True, archived=False)
|
||||
n = Note(body="b", color="blue", pinned=True, archived=False)
|
||||
s = n.serialize()
|
||||
assert s["title"] == "t"
|
||||
assert "title" not in s # there is no title field any more (M13 step 3)
|
||||
assert s["body"] == "b"
|
||||
assert s["color"] == "blue"
|
||||
assert s["pinned"] is True
|
||||
@@ -122,30 +124,34 @@ async def test_reorder_requires_auth(app):
|
||||
# notice a route coming back, and the removal is one commit rather than a fossil.
|
||||
|
||||
|
||||
def test_derive_display_title_explicit_wins():
|
||||
assert derive_display_title("My Title", "some body line") == "My Title"
|
||||
assert derive_display_title(" Padded ", "body") == "Padded"
|
||||
|
||||
|
||||
def test_derive_display_title_from_first_body_line():
|
||||
assert derive_display_title(None, "first line\nsecond line") == "first line"
|
||||
assert derive_display_title("", " spaced first \nnext") == "spaced first"
|
||||
def test_derive_display_title_is_the_first_body_line():
|
||||
assert derive_display_title("first line\nsecond line") == "first line"
|
||||
assert derive_display_title(" spaced first \nnext") == "spaced first"
|
||||
# leading blank/whitespace lines are skipped to the first line with content
|
||||
assert derive_display_title(None, "\n \nreal line\nmore") == "real line"
|
||||
# a whitespace-only title falls through to the body
|
||||
assert derive_display_title(" ", "body wins") == "body wins"
|
||||
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_empty():
|
||||
assert derive_display_title(None, None) == ""
|
||||
assert derive_display_title("", "") == ""
|
||||
assert derive_display_title(" ", " \n ") == ""
|
||||
assert derive_display_title(None) == ""
|
||||
assert derive_display_title("") == ""
|
||||
assert derive_display_title(" \n ", None) == ""
|
||||
assert derive_display_title(" \n ", " ") == ""
|
||||
|
||||
|
||||
def test_derive_display_title_caps_length():
|
||||
long = "x" * 300
|
||||
assert derive_display_title(None, long) == "x" * 200
|
||||
assert derive_display_title(long, "body") == "x" * 200
|
||||
assert derive_display_title(long) == "x" * 200
|
||||
# the item fallback is capped on the same rule
|
||||
assert derive_display_title("", long) == "x" * 200
|
||||
|
||||
|
||||
def test_parse_tags():
|
||||
@@ -317,9 +323,13 @@ def test_usec_to_dt():
|
||||
assert _usec_to_dt(None) is None
|
||||
|
||||
|
||||
def test_keep_spec_list_note():
|
||||
def test_keep_spec_list_note_keeps_its_text_too():
|
||||
# Keep's own notes carry one or the other, but its textContent used to be
|
||||
# DISCARDED whenever a note also had listContent, because a note could only be
|
||||
# one kind. A note holds both now, so nothing is dropped on the way in.
|
||||
kn = {
|
||||
"title": "Groceries",
|
||||
"textContent": "for the weekend",
|
||||
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
|
||||
"labels": [{"name": "shopping"}],
|
||||
"color": "TEAL",
|
||||
@@ -330,7 +340,7 @@ def test_keep_spec_list_note():
|
||||
"userEditedTimestampUsec": 1600000100000000,
|
||||
}
|
||||
spec = _keep_spec(kn, "Takeout/Keep")
|
||||
assert spec["kind"] == "list"
|
||||
assert spec["body"] == "for the weekend"
|
||||
assert spec["color"] == "teal"
|
||||
assert spec["pinned"] is True
|
||||
assert spec["archived"] is False
|
||||
@@ -348,7 +358,6 @@ def test_keep_spec_text_note_folds_annotation_urls_and_maps_color():
|
||||
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
|
||||
}
|
||||
spec = _keep_spec(kn, "Takeout/Keep")
|
||||
assert spec["kind"] == "text"
|
||||
assert "https://example.com" in spec["body"]
|
||||
assert spec["color"] == "orange"
|
||||
# attachment path is resolved relative to the note JSON's folder
|
||||
@@ -360,7 +369,6 @@ def test_native_spec_roundtrip_fields():
|
||||
"title": "T",
|
||||
"body": "b",
|
||||
"color": "blue",
|
||||
"kind": "text",
|
||||
"pinned": True,
|
||||
"archived": False,
|
||||
"created_at": "2026-07-19T00:00:00+00:00",
|
||||
@@ -369,6 +377,8 @@ def test_native_spec_roundtrip_fields():
|
||||
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
|
||||
}
|
||||
spec = _native_spec(n)
|
||||
# The spec still CARRIES a title — an export taken before M13 has one, and
|
||||
# _create_imported_note folds it into the body rather than dropping it.
|
||||
assert spec["title"] == "T"
|
||||
assert spec["body"] == "b"
|
||||
assert spec["color"] == "blue"
|
||||
@@ -377,3 +387,22 @@ def test_native_spec_roundtrip_fields():
|
||||
assert spec["created_at"].year == 2026
|
||||
assert spec["labels"] == ["x"]
|
||||
assert spec["attachments"] == [{"file": "attachments/ab/img.png", "mime": "image/png"}]
|
||||
|
||||
|
||||
def test_detect_urls_finds_each_link_once_in_order():
|
||||
body = "see https://example.com/a and https://example.com/b\nand https://example.com/a again"
|
||||
assert detect_urls(body) == ["https://example.com/a", "https://example.com/b"]
|
||||
|
||||
|
||||
def test_detect_urls_trims_sentence_punctuation():
|
||||
# A URL can end in most punctuation; a SENTENCE containing one usually doesn't.
|
||||
assert detect_urls("read https://example.com/page.") == ["https://example.com/page"]
|
||||
assert detect_urls("(see https://example.com/x)") == ["https://example.com/x"]
|
||||
# …but a path that legitimately ends in a slash or a dash keeps it.
|
||||
assert detect_urls("https://example.com/dir/") == ["https://example.com/dir/"]
|
||||
|
||||
|
||||
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("") == []
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""The proxy trust boundary.
|
||||
|
||||
The whole security property is "a caller cannot forge their own address", and it rests
|
||||
on counting in from the RIGHT of the header rather than the left. These are the cases
|
||||
that tell the two apart — pure functions, no request context, no database.
|
||||
"""
|
||||
from thoughtsync.proxy import forwarded_for, trusted_entry
|
||||
from thoughtsync.settings import live
|
||||
|
||||
PEER = "10.0.0.1" # the socket address: our own proxy, or the caller when unproxied
|
||||
|
||||
|
||||
def test_default_is_one_hop():
|
||||
# One reverse proxy terminating TLS — this deployment, and the only shape that is
|
||||
# safe to assume. A wrong default here is a silent security bug, not a preference.
|
||||
#
|
||||
# Read through live() rather than the registry: live() is what proxy.py actually
|
||||
# calls, and it is seeded from the defaults at import time so the value is right
|
||||
# before the first database read. A boot that never reached the DB must still
|
||||
# count one hop, not zero.
|
||||
assert live("trusted_proxy_hops") == 1
|
||||
|
||||
|
||||
def test_no_proxy_ignores_the_header_entirely():
|
||||
# hops=0 says nothing in front of us appends anything, so the header can only be
|
||||
# something a caller invented.
|
||||
assert forwarded_for("1.2.3.4", PEER, 0) == PEER
|
||||
|
||||
|
||||
def test_one_hop_reads_what_our_proxy_wrote():
|
||||
assert forwarded_for("203.0.113.7", PEER, 1) == "203.0.113.7"
|
||||
|
||||
|
||||
def test_a_forged_prefix_is_never_selected():
|
||||
# THE test. A caller sends `X-Forwarded-For: 1.2.3.4`; our proxy appends the
|
||||
# address it actually saw. Reading from the left would hand the caller a fresh
|
||||
# rate-limit bucket for every value they invent.
|
||||
assert forwarded_for("1.2.3.4, 203.0.113.7", PEER, 1) == "203.0.113.7"
|
||||
# …and padding it doesn't help either.
|
||||
assert forwarded_for("a, b, c, d, 203.0.113.7", PEER, 1) == "203.0.113.7"
|
||||
|
||||
|
||||
def test_two_hops_sees_past_a_cdn():
|
||||
# Cloudflare appended the real client; our proxy appended Cloudflare.
|
||||
assert forwarded_for("203.0.113.7, 172.16.0.5", PEER, 2) == "203.0.113.7"
|
||||
assert forwarded_for("1.2.3.4, 203.0.113.7, 172.16.0.5", PEER, 2) == "203.0.113.7"
|
||||
|
||||
|
||||
def test_a_short_header_falls_back_rather_than_reaching_left():
|
||||
# Fewer proxies than configured. Reaching further left would start believing
|
||||
# entries no proxy of ours wrote, so the safe direction is the socket address —
|
||||
# at worst several callers share one bucket.
|
||||
assert forwarded_for("203.0.113.7", PEER, 2) == PEER
|
||||
assert forwarded_for("", PEER, 1) == PEER
|
||||
|
||||
|
||||
def test_malformed_headers_do_not_crash_or_leak_empties():
|
||||
assert forwarded_for(",,,", PEER, 1) == PEER
|
||||
assert forwarded_for(" , 203.0.113.7 , ", PEER, 1) == "203.0.113.7"
|
||||
|
||||
|
||||
def test_the_key_is_length_bounded():
|
||||
# It becomes a dict key in the limiter; an unbounded header must not become an
|
||||
# unbounded allocation.
|
||||
assert len(forwarded_for("x" * 5000, PEER, 1)) <= 64
|
||||
|
||||
|
||||
def test_trusted_entry_reports_absence_rather_than_guessing():
|
||||
# `is_https` needs to tell "no trusted entry" apart from "an entry saying http",
|
||||
# which is why this returns None rather than a default.
|
||||
assert trusted_entry("", 1) is None
|
||||
assert trusted_entry("https", 0) is None
|
||||
assert trusted_entry("http, https", 1) == "https"
|
||||
+19
-22
@@ -13,10 +13,18 @@ import time
|
||||
import pytest
|
||||
|
||||
from thoughtsync import ratelimit
|
||||
from thoughtsync.settings import live
|
||||
from thoughtsync.app import create_app
|
||||
from thoughtsync.ratelimit import SlidingWindow
|
||||
|
||||
|
||||
def window(limit: int, window_s: float) -> SlidingWindow:
|
||||
"""A fixed-value window. The real ones read their numbers from the settings cache
|
||||
so an admin's change applies immediately; these tests are about the counting, not
|
||||
about where the numbers come from."""
|
||||
return SlidingWindow(lambda: limit, lambda: window_s)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_counters():
|
||||
ratelimit.reset_all()
|
||||
@@ -30,7 +38,7 @@ def app():
|
||||
|
||||
|
||||
def test_under_the_limit_is_not_blocked():
|
||||
w = SlidingWindow(limit=3, window_s=60)
|
||||
w = window(3, 60)
|
||||
for i in range(3):
|
||||
assert w.retry_after("k", now=i) is None
|
||||
w.record("k", now=i)
|
||||
@@ -38,7 +46,7 @@ def test_under_the_limit_is_not_blocked():
|
||||
|
||||
|
||||
def test_window_slides_rather_than_resetting():
|
||||
w = SlidingWindow(limit=2, window_s=60)
|
||||
w = window(2, 60)
|
||||
w.record("k", now=0)
|
||||
w.record("k", now=30)
|
||||
assert w.retry_after("k", now=31) is not None
|
||||
@@ -50,7 +58,7 @@ def test_window_slides_rather_than_resetting():
|
||||
|
||||
|
||||
def test_retry_after_points_past_the_oldest_hit():
|
||||
w = SlidingWindow(limit=1, window_s=100)
|
||||
w = window(1, 100)
|
||||
w.record("k", now=10)
|
||||
wait = w.retry_after("k", now=40)
|
||||
# The hit at t=10 leaves the window at t=110, i.e. 70s away. Rounded up, never
|
||||
@@ -62,14 +70,14 @@ def test_retry_after_points_past_the_oldest_hit():
|
||||
|
||||
|
||||
def test_keys_are_counted_separately():
|
||||
w = SlidingWindow(limit=1, window_s=60)
|
||||
w = window(1, 60)
|
||||
w.record("a", now=0)
|
||||
assert w.retry_after("a", now=1) is not None
|
||||
assert w.retry_after("b", now=1) is None
|
||||
|
||||
|
||||
def test_forget_clears_one_key():
|
||||
w = SlidingWindow(limit=1, window_s=60)
|
||||
w = window(1, 60)
|
||||
w.record("a", now=0)
|
||||
w.record("b", now=0)
|
||||
w.forget("a")
|
||||
@@ -81,7 +89,7 @@ def test_bucket_count_is_bounded(monkeypatch):
|
||||
# An attacker rotating a forged X-Forwarded-For must not be able to grow this
|
||||
# dict without limit — the limiter cannot become the exhaustion it prevents.
|
||||
monkeypatch.setattr(ratelimit, "MAX_BUCKETS", 8)
|
||||
w = SlidingWindow(limit=5, window_s=60)
|
||||
w = window(5, 60)
|
||||
for i in range(50):
|
||||
w.record(f"addr-{i}", now=i)
|
||||
assert len(w._hits) <= 8
|
||||
@@ -97,7 +105,7 @@ async def test_login_starts_refusing(app):
|
||||
# at t=0..9 are fifteen minutes stale the moment the route reads
|
||||
# `time.monotonic()` and get pruned before they can refuse anything.
|
||||
now = time.monotonic()
|
||||
for _ in range(ratelimit.ACCOUNT_LIMIT):
|
||||
for _ in range(live("signin_limit_per_account")):
|
||||
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
|
||||
resp = await client.post("/api/auth/login", json=body)
|
||||
assert resp.status_code == 429
|
||||
@@ -109,7 +117,7 @@ async def test_login_starts_refusing(app):
|
||||
async def test_device_login_shares_the_account_counter(app):
|
||||
client = app.test_client()
|
||||
now = time.monotonic()
|
||||
for _ in range(ratelimit.ACCOUNT_LIMIT):
|
||||
for _ in range(live("signin_limit_per_account")):
|
||||
ratelimit.sign_in_by_account.record("someone@example.com", now=now)
|
||||
resp = await client.post(
|
||||
"/api/auth/device-login",
|
||||
@@ -123,26 +131,15 @@ async def test_device_login_shares_the_account_counter(app):
|
||||
async def test_register_is_throttled_by_address(app):
|
||||
client = app.test_client()
|
||||
now = time.monotonic()
|
||||
for _ in range(ratelimit.REGISTER_LIMIT):
|
||||
for _ in range(live("register_limit_per_address")):
|
||||
ratelimit.register_by_address.record("203.0.113.9", now=now)
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "new@example.com", "password": "a-long-enough-password"},
|
||||
# One entry, so with the default single trusted hop this IS the address the
|
||||
# limiter keys on. The forged-prefix cases live in test_proxy.py.
|
||||
headers={"X-Forwarded-For": "203.0.113.9"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
async def test_client_address_prefers_the_forwarded_client(app):
|
||||
# Behind a reverse proxy, remote_addr is the PROXY for every request on earth —
|
||||
# keying on it would rate-limit the entire internet as one caller. The leftmost
|
||||
# X-Forwarded-For entry is the original client.
|
||||
async with app.test_request_context("/", headers={"X-Forwarded-For": "198.51.100.4, 10.0.0.1"}):
|
||||
assert ratelimit.client_address() == "198.51.100.4"
|
||||
|
||||
|
||||
async def test_client_address_falls_back_to_the_peer(app):
|
||||
async with app.test_request_context("/"):
|
||||
# No proxy header: whatever the peer address is, it must be a usable key
|
||||
# rather than an empty string sharing one bucket with everyone.
|
||||
assert ratelimit.client_address()
|
||||
|
||||
Reference in New Issue
Block a user