0.2.0 — a notebook in your pocket, ready to be hosted (#3)
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 15s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s

This commit was merged in pull request #3.
This commit is contained in:
2026-08-23 16:38:00 -04:00
89 changed files with 2996 additions and 2226 deletions
+10
View File
@@ -39,6 +39,16 @@ POSTGRES_PASSWORD=
# 127.0.0.1 so only the proxy can talk to it. # 127.0.0.1 so only the proxy can talk to it.
#THOUGHTSYNC_BIND=0.0.0.0 #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 # Database identity. Changing these AFTER the first start does not rename anything
# that already exists — the volume keeps whatever the first run created. # that already exists — the volume keeps whatever the first run created.
#POSTGRES_USER=thoughtsync #POSTGRES_USER=thoughtsync
+74 -1
View File
@@ -184,7 +184,80 @@ jobs:
run: uv pip install --python /opt/venv/bin/python -e ".[dev]" run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Run tests - 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: build:
name: Build & push image name: Build & push image
Generated
+1 -1
View File
@@ -4189,7 +4189,7 @@ dependencies = [
[[package]] [[package]]
name = "thoughtsync-desktop" name = "thoughtsync-desktop"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
+5
View File
@@ -101,6 +101,11 @@ Then open `http://<host>:5000` and register — **the first account becomes the
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start. - The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) · - **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
`:<git-sha>` (immutable, for pinning / rollback). `:<git-sha>` (immutable, for pinning / rollback).
- **Putting it on the public internet:** there are four things to do first — close
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
port, and back up the attachment volume as well as the database. See
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
hardens on its own and what it deliberately doesn't.
- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the - **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the
browser's install button) for an app-like window. Browsers only offer install over a browser's install button) for an app-like window. Browsers only offer install over a
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach **secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
@@ -0,0 +1,67 @@
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
Revision ID: 0023
Revises: 0022
Create Date: 2026-08-22
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
the note and every inbound link stops matching. The old answer was to rewrite the
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
title existed to hold still, untenable once a note's name is just its first body
line (M13).
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
that doesn't exist yet is a supported way to create one.
The backfill is safe to run bluntly because note_links is DERIVED data — every row
is recomputed from the source body on the next save regardless.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0023"
down_revision = "0022"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"note_links",
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_note_links_target",
"note_links",
"notes",
["target_id"],
["id"],
# A deleted target un-resolves its inbound links rather than deleting them:
# the link text is still in the source's body, and it should read as pointing
# at something that isn't there — which is also what lets it re-resolve if a
# note of that name appears again.
ondelete="SET NULL",
)
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
# Resolve what can be resolved right now, scoped to the source's owner so a link
# can never bind to another user's note.
op.execute(
"""
UPDATE note_links AS nl
SET target_id = t.id
FROM notes AS src, notes AS t
WHERE nl.source_id = src.id
AND t.owner_id = src.owner_id
AND t.deleted_at IS NULL
AND lower(btrim(t.display_title)) = nl.target_norm
AND t.id <> src.id
"""
)
def downgrade() -> None:
op.drop_index("ix_note_links_target_id", table_name="note_links")
op.drop_constraint("fk_note_links_target", "note_links", type_="foreignkey")
op.drop_column("note_links", "target_id")
+54
View File
@@ -0,0 +1,54 @@
"""drop note_links — [[wiki-links]] are removed (note 2897)
Revision ID: 0024
Revises: 0023
Create Date: 2026-08-22
ThoughtSync is an intermediary surface for capture and recall; a linking system is
organization, which is not what it is for. Backlinks, the graph and the name index
went with it.
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
than deleted. It shipped in an image and may already be applied, and removing an
applied revision would strand a database's alembic_version pointer. So the column is
dropped here along with the table it lived on, and the history stays honest about the
fact that it existed for a day.
No down-migration data concern: note_links was always DERIVED from note bodies. The
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
lost by this.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0024"
down_revision = "0023"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("note_links")
def downgrade() -> None:
op.create_table(
"note_links",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"source_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"target_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("target_norm", sa.Text(), nullable=False),
)
op.create_index("ix_note_links_target", "note_links", ["target_norm"])
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
+35
View File
@@ -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"))
+82
View File
@@ -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( ComposeSheet(
saving = board.state.saving, saving = board.state.saving,
onDismiss = { composing = false }, onDismiss = { composing = false },
onSave = { kind, title, content -> onSave = { content ->
board.create(kind, title, content) board.create(content)
composing = false composing = false
}, },
) )
@@ -50,9 +50,6 @@ sealed interface Destination {
) : 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. */ /** Everything the board renders from, in one immutable snapshot. */
data class BoardState( data class BoardState(
val destination: Destination = Destination.Notes, 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 * Blank input is ignored rather than rejected: an empty save is a slip, not a
* mistake worth interrupting someone over. * mistake worth interrupting someone over.
*/ */
fun create( fun create(content: String) {
kind: DraftKind,
title: String,
content: String,
) {
val cleanTitle = title.trim()
val cleanContent = content.trim() val cleanContent = content.trim()
if (cleanTitle.isEmpty() && cleanContent.isEmpty()) return if (cleanContent.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
state = state.copy(saving = true) state = state.copy(saving = true)
state = state =
try { 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 // 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 // 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 // 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.Close -> state = state.copy(editing = null)
EditorAction.DismissError -> dismissError() EditorAction.DismissError -> dismissError()
// Text is the only edit that batches: title and body are typed // Saved on close rather than per keystroke, so a session of typing
// together and saved together on close, so they cost one write and // costs one write and one revision snapshot.
// one revision snapshot rather than two of each.
is EditorAction.SaveText -> is EditorAction.SaveText ->
mutate { mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
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),
),
)
}
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color)) 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 // 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 // you often pin while still reading — so unlike the three below, it
// deliberately leaves the editor open. // deliberately leaves the editor open.
@@ -331,6 +302,10 @@ class BoardViewModel(
null null
} }
// An empty first item: the checklist editor appears the moment the note
// has one, and an empty row is what someone can type straight into.
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
is EditorAction.AddItem -> is EditorAction.AddItem ->
action.text.trim().takeIf { it.isNotEmpty() }?.let { text -> action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
mutate { it.addItem(id, text) } mutate { it.addItem(id, text) }
@@ -467,26 +442,8 @@ private fun query(
labelId: String? = null, labelId: String? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null) ) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
private fun draft( private fun draft(content: String): NoteDraft =
kind: DraftKind, // The core names the note from the body's first line, so a captured thought is
title: String, // findable without anyone being asked to name it. A checklist is added afterwards,
content: String, // in the editor — it is something a note HAS, not a different thing to capture.
): NoteDraft = NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
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() },
)
}
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -58,28 +57,26 @@ import com.fabledsword.thoughtsync.R
fun ComposeSheet( fun ComposeSheet(
saving: Boolean, saving: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (DraftKind, String, String) -> Unit, onSave: (String) -> Unit,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Saveable, not just remembered: a rotation mid-sentence is the same lost // Saveable, not just remembered: a rotation mid-sentence is the same lost
// thought as a discarded one, and it was losing it before this. // 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("") } var content by rememberSaveable { mutableStateOf("") }
val contentFocus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() }
val written = title.isNotBlank() || content.isNotBlank() val written = content.isNotBlank()
val leave = { if (written) onSave(kind, title, content) else onDismiss() } val leave = { if (written) onSave(content) else onDismiss() }
// Land in the body, not the title. Most captures are a thought, not a titled // Straight into the one field there is. A capture is a thought, and every field
// document, and making someone tab past an optional field is the difference // someone has to tab past is the difference between "under a second" and not —
// 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() } LaunchedEffect(Unit) { contentFocus.requestFocus() }
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped // 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 // + 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. // 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) { ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
Column( Column(
@@ -91,43 +88,20 @@ fun ComposeSheet(
.navigationBarsPadding(), .navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { // No note/list switch any more: there is one thing to capture. A
FilterChip( // checklist is added to a note in the editor, once there is a note.
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,
)
PlainTextField( PlainTextField(
value = content, value = content,
onValueChange = { content = it }, onValueChange = { content = it },
modifier = Modifier.focusRequester(contentFocus), modifier = Modifier.focusRequester(contentFocus),
hint = hint = R.string.compose_body_hint,
if (kind == DraftKind.LIST) {
R.string.compose_list_hint
} else {
R.string.compose_body_hint
},
minLines = MIN_CONTENT_LINES, minLines = MIN_CONTENT_LINES,
) )
SheetActions( SheetActions(
canSave = !saving && written, canSave = !saving && written,
onDiscard = onDismiss, onDiscard = onDismiss,
onSave = { onSave(kind, title, content) }, onSave = { onSave(content) },
) )
} }
} }
@@ -22,7 +22,6 @@ sealed interface EditorAction {
data object DismissError : EditorAction data object DismissError : EditorAction
data class SaveText( data class SaveText(
val title: String,
val body: String, val body: String,
) : EditorAction ) : EditorAction
@@ -31,13 +30,13 @@ sealed interface EditorAction {
) : EditorAction ) : EditorAction
/** /**
* Note checklist. * Give this note a checklist.
* *
* Only `kind` changes: the body text and any existing items both stay where * Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
* they are, so switching back and forth is lossless and a mis-tap costs * so nothing moves and nothing is swapped: the body stays exactly where it is and
* nothing. * the note gains a first, empty item for someone to type into.
*/ */
data object ToggleKind : EditorAction data object AddChecklist : EditorAction
data class SetPinned( data class SetPinned(
val pinned: Boolean, val pinned: Boolean,
@@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Close 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.MoreVert
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
@@ -84,15 +83,16 @@ fun EditorBottomBar(
contentDescription = stringResource(R.string.editor_reminder), contentDescription = stringResource(R.string.editor_reminder),
) )
} }
IconButton(onClick = { onAction(EditorAction.ToggleKind) }) { // Adds the first checklist item, which is what makes the checklist
val list = note.kind == KIND_LIST // editor appear. Hidden once the note already has one — there is nothing
Icon( // left to add that the checklist's own "+" row doesn't do better.
if (list) Icons.Filled.Create else Icons.AutoMirrored.Filled.List, if (note.items.isEmpty()) {
contentDescription = IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
stringResource( Icon(
if (list) R.string.editor_make_note else R.string.editor_make_list, Icons.AutoMirrored.Filled.List,
), contentDescription = stringResource(R.string.editor_add_checklist),
) )
}
} }
} }
@@ -21,7 +21,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -50,23 +49,10 @@ fun NoteCard(
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS)) .border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp), .padding(12.dp),
) { ) {
// A title only renders when one was actually set. `displayTitle` is // Body then checklist, in order — a note can carry both (M13 step 2), and
// derived from the first body line when it wasn't, so printing both would // nothing above them: the first line of the body IS the note's name, at the
// show the same text twice. // same weight as the rest of it (M13 steps 3 and 4).
note.title?.takeIf { it.isNotBlank() }?.let { title -> if (note.body.isNotBlank()) {
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()) {
Text( Text(
text = note.body, text = note.body,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -74,10 +60,14 @@ fun NoteCard(
overflow = TextOverflow.Ellipsis, 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 // A note with no body and no items still has to occupy the board legibly —
// board legibly — otherwise it reads as a rendering bug. // otherwise it reads as a rendering bug.
if (note.title.isNullOrBlank() && note.body.isBlank() && note.items.isEmpty()) { if (note.body.isBlank() && note.items.isEmpty()) {
Text( Text(
text = stringResource(R.string.board_empty_note), text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -30,7 +30,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label 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 // 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. // 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 body by remember(note.id) { mutableStateOf(note.body) }
var picker by remember(note.id) { mutableStateOf(Picker.NONE) } var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(note.id) { mutableStateOf(false) } 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 // would bump `updated_at`, mark the note dirty for sync, and snapshot a
// revision identical to the one before it. // revision identical to the one before it.
val flush = { val flush = {
if (!readOnly && (title != note.title.orEmpty() || body != note.body)) { if (!readOnly && body != note.body) {
onAction(EditorAction.SaveText(title, body)) onAction(EditorAction.SaveText(body))
} }
} }
val leave = { val leave = {
@@ -142,24 +140,21 @@ fun NoteEditorScreen(
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) }) ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
} }
// One field. A note is its body; its NAME is that body's first line, so
// there is nothing separate to type into and nothing to render bolder
// than the line beneath it (M13 steps 3 and 4).
EditorField( EditorField(
value = title, value = body,
onValueChange = { title = it }, onValueChange = { body = it },
hint = R.string.editor_title_hint, hint = R.string.editor_body_hint,
enabled = !readOnly, enabled = !readOnly,
bold = true, minLines = MIN_BODY_LINES,
) )
if (note.kind == KIND_LIST) { // 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) ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
} else {
EditorField(
value = body,
onValueChange = { body = it },
hint = R.string.editor_body_hint,
enabled = !readOnly,
minLines = MIN_BODY_LINES,
)
} }
if (note.labels.isNotEmpty()) { 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 * 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 * the note's colour, and a filled field would draw a second surface over the first
* and turn a note into a form. * 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 @Composable
private fun EditorField( private fun EditorField(
@@ -262,7 +261,6 @@ private fun EditorField(
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
@StringRes hint: Int, @StringRes hint: Int,
enabled: Boolean, enabled: Boolean,
bold: Boolean = false,
minLines: Int = 1, minLines: Int = 1,
) { ) {
PlainTextField( PlainTextField(
@@ -270,16 +268,8 @@ private fun EditorField(
onValueChange = onValueChange, onValueChange = onValueChange,
hint = hint, hint = hint,
enabled = enabled, 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, minLines = minLines,
textStyle = textStyle = MaterialTheme.typography.bodyLarge,
if (bold) {
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
} else {
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"
+1 -7
View File
@@ -10,11 +10,7 @@
<!-- Compose sheet --> <!-- Compose sheet -->
<string name="compose_open">New note</string> <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_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_discard">Discard</string>
<string name="compose_save">Save</string> <string name="compose_save">Save</string>
@@ -41,14 +37,12 @@
<!-- Editor --> <!-- Editor -->
<string name="board_open_note">Open note</string> <string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string> <string name="editor_back">Back to notes</string>
<string name="editor_title_hint">Title</string> <string name="editor_add_checklist">Add a checklist</string>
<string name="editor_body_hint">Note</string> <string name="editor_body_hint">Note</string>
<string name="editor_add_item">Add item</string> <string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string> <string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string> <string name="editor_remove_label">Remove label</string>
<string name="editor_reminder">Set a reminder</string> <string name="editor_reminder">Set a reminder</string>
<string name="editor_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_more">More actions</string>
<string name="editor_pin">Pin</string> <string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string> <string name="editor_unpin">Unpin</string>
+25 -45
View File
@@ -568,12 +568,10 @@ mod tests {
dir.to_string_lossy().into_owned() dir.to_string_lossy().into_owned()
} }
fn draft(title: &str, body: &str) -> NoteDraft { fn draft(body: &str) -> NoteDraft {
NoteDraft { NoteDraft {
title: title.to_string(),
body: body.to_string(), body: body.to_string(),
color: "default".to_string(), color: "default".to_string(),
kind: None,
items: None, items: None,
} }
} }
@@ -588,62 +586,50 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app let created = app
.create_note(draft("Groceries", "milk")) .create_note(draft("Groceries\nmilk"))
.expect("create should succeed"); .expect("create should succeed");
assert_eq!(created.title.as_deref(), Some("Groceries")); assert_eq!(created.body, "Groceries\nmilk");
assert_eq!(created.body, "milk");
let fetched = app let fetched = app
.get_note(created.id.clone()) .get_note(created.id.clone())
.expect("get should succeed"); .expect("get should succeed");
assert_eq!(fetched.id, created.id); 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"); assert_eq!(fetched.display_title, "Groceries");
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
/// A body-only note still has to be nameable — that is what `display_title` is /// Every note has to be nameable — that is what `display_title` is for, and the
/// for, and the Android board relies on it exactly as the desktop does. /// Android board relies on it exactly as the desktop does.
#[test] #[test]
fn body_only_notes_still_have_a_display_title() { fn a_note_is_named_by_its_first_line() {
let dir = scratch_dir(); let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let created = app let created = app
.create_note(draft("", "just a thought")) .create_note(draft("just a thought"))
.expect("create should succeed"); .expect("create should succeed");
assert_eq!(created.title, None);
assert_eq!(created.display_title, "just a thought"); assert_eq!(created.display_title, "just a thought");
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
/// Clearing a field and setting one are different edits, and the difference has /// The hole that made removing the title unsafe until checklists stopped being
/// to survive the trip through the patch object. /// their own kind of thing: a note with no body text still needs a name.
#[test] #[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 dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); 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 let created = app
.update_note( .create_note(NoteDraft {
note.id.clone(), body: String::new(),
vec![NoteEdit::Title { color: "default".to_string(),
value: "Second".to_string(), items: Some(vec!["milk".to_string(), "eggs".to_string()]),
}], })
) .expect("create should succeed");
.expect("rename"); assert_eq!(created.display_title, "milk");
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"
);
std::fs::remove_dir_all(&dir).ok(); 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 app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app let note = app
.create_note(NoteDraft { .create_note(NoteDraft {
title: "Packing".to_string(), body: "Packing".to_string(),
body: String::new(),
color: "default".to_string(), color: "default".to_string(),
kind: Some("list".to_string()),
items: Some(vec!["socks".to_string()]), items: Some(vec!["socks".to_string()]),
}) })
.expect("create"); .expect("create");
@@ -730,7 +714,7 @@ mod tests {
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app let note = app
.create_note(draft("Trip", "book the ferry #travel")) .create_note(draft("Trip\nbook the ferry #travel"))
.expect("create"); .expect("create");
assert_eq!( assert_eq!(
note.labels.len(), note.labels.len(),
@@ -769,7 +753,7 @@ mod tests {
fn deleting_forever_removes_the_note() { fn deleting_forever_removes_the_note() {
let dir = scratch_dir(); let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); 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()) app.delete_note_forever(note.id.clone())
.expect("delete forever"); .expect("delete forever");
@@ -786,7 +770,7 @@ mod tests {
fn reminders_can_be_snoozed_and_completed() { fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir(); let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); 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); assert_eq!(note.remind_at, None);
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze"); 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() { fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
let dir = scratch_dir(); let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open"); let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app let note = app.create_note(draft("Water the plants")).expect("create");
.create_note(draft("Water the plants", ""))
.expect("create");
let armed = app let armed = app
.update_note( .update_note(
@@ -851,9 +833,7 @@ mod tests {
// A one-off clears BOTH fields, so an unrecognised rule cannot linger // A one-off clears BOTH fields, so an unrecognised rule cannot linger
// invisibly on a note with no reminder. // invisibly on a note with no reminder.
let once = app let once = app.create_note(draft("Post the letter")).expect("create");
.create_note(draft("Post the letter", ""))
.expect("create");
app.update_note( app.update_note(
once.id.clone(), once.id.clone(),
vec![NoteEdit::RemindAt { vec![NoteEdit::RemindAt {
+21 -49
View File
@@ -29,13 +29,11 @@ use thoughtsync_core::sync::state as core_state;
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct Note { pub struct Note {
pub id: String, pub id: String,
pub title: Option<String>, /// The note's NAME: its first non-blank body line, else its first checklist item.
/// Title if set, else the first body line — always present, so a body-only note /// Always present. Derived by the core, never stored.
/// is still nameable. Derived by the core, never stored.
pub display_title: String, pub display_title: String,
pub body: String, pub body: String,
pub color: String, pub color: String,
pub kind: String,
pub position: i64, pub position: i64,
pub pinned: bool, pub pinned: bool,
pub archived: bool, pub archived: bool,
@@ -130,11 +128,9 @@ impl From<core_models::Note> for Note {
// Exhaustive on purpose — see the module header. // Exhaustive on purpose — see the module header.
let core_models::Note { let core_models::Note {
id, id,
title,
display_title, display_title,
body, body,
color, color,
kind,
position, position,
pinned, pinned,
archived, archived,
@@ -151,11 +147,9 @@ impl From<core_models::Note> for Note {
} = value; } = value;
Note { Note {
id, id,
title,
display_title, display_title,
body, body,
color, color,
kind,
position, position,
pinned, pinned,
archived, archived,
@@ -294,7 +288,6 @@ pub struct NoteQuery {
pub struct NoteFacets { pub struct NoteFacets {
pub q: Option<String>, pub q: Option<String>,
pub color: Option<String>, pub color: Option<String>,
pub kind: Option<String>,
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>, pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>, pub has_attachment: Option<bool>,
@@ -324,7 +317,6 @@ impl From<NoteFacets> for core_models::Facets {
let NoteFacets { let NoteFacets {
q, q,
color, color,
kind,
label, label,
has_reminder, has_reminder,
has_attachment, has_attachment,
@@ -334,7 +326,6 @@ impl From<NoteFacets> for core_models::Facets {
core_models::Facets { core_models::Facets {
q, q,
color, color,
kind,
label, label,
has_reminder, has_reminder,
has_attachment, has_attachment,
@@ -347,31 +338,18 @@ impl From<NoteFacets> for core_models::Facets {
/// A new note. /// A new note.
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft { pub struct NoteDraft {
pub title: String,
pub body: String, pub body: String,
/// "default" unless the user picked a colour. /// "default" unless the user picked a colour.
pub color: String, pub color: String,
pub kind: Option<String>, /// Checklist lines. A note can carry both a body and items (M13 step 2), so this
/// Checklist lines, for `kind = "checklist"`. /// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>, pub items: Option<Vec<String>>,
} }
impl From<NoteDraft> for core_models::NoteCreateInput { impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self { fn from(value: NoteDraft) -> Self {
let NoteDraft { let NoteDraft { body, color, items } = value;
title, core_models::NoteCreateInput { body, color, items }
body,
color,
kind,
items,
} = value;
core_models::NoteCreateInput {
title,
body,
color,
kind,
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 /// 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 /// 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 — and Kotlin has no way to express the third with a nullable field.
/// null` in a data class is indistinguishable from `title` unset, so the editor /// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
/// could never clear a title. Explicit `Clear*` variants say it out loud, and Kotlin /// the editor could never clear a reminder. Explicit `Clear*` variants say it out
/// gets a sealed class it can `when` over exhaustively. /// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
#[derive(Debug, Clone, uniffi::Enum)] #[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit { pub enum NoteEdit {
Title { value: String },
ClearTitle,
Body { value: String }, Body { value: String },
Color { value: String }, Color { value: String },
Kind { value: String },
Pinned { value: bool }, Pinned { value: bool },
Archived { value: bool }, Archived { value: bool },
RemindAt { value: String }, RemindAt { value: String },
@@ -408,11 +383,8 @@ impl NoteEdit {
fn entry(self) -> (&'static str, serde_json::Value) { fn entry(self) -> (&'static str, serde_json::Value) {
use serde_json::Value; use serde_json::Value;
match self { match self {
NoteEdit::Title { value } => ("title", Value::String(value)),
NoteEdit::ClearTitle => ("title", Value::Null),
NoteEdit::Body { value } => ("body", Value::String(value)), NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", 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::Pinned { value } => ("pinned", Value::Bool(value)),
NoteEdit::Archived { value } => ("archived", Value::Bool(value)), NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
NoteEdit::RemindAt { value } => ("remind_at", Value::String(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. /// 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, /// Later edits win on a repeated key, which is what a caller batching "set a
/// then clear title" would expect. /// reminder, then clear it" would expect.
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value { pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
for edit in edits { for edit in edits {
@@ -707,14 +679,14 @@ mod tests {
#[test] #[test]
fn a_set_and_a_clear_are_different_patch_entries() { fn a_set_and_a_clear_are_different_patch_entries() {
let set = patch_from(vec![NoteEdit::Title { let set = patch_from(vec![NoteEdit::RemindAt {
value: "x".to_string(), 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!( assert!(
cleared["title"].is_null(), cleared["remind_at"].is_null(),
"a clear must reach the store as JSON null — an absent key means \ "a clear must reach the store as JSON null — an absent key means \
'leave alone', which is a different instruction" 'leave alone', which is a different instruction"
); );
@@ -730,11 +702,11 @@ mod tests {
#[test] #[test]
fn later_edits_win_on_a_repeated_field() { fn later_edits_win_on_a_repeated_field() {
let patch = patch_from(vec![ let patch = patch_from(vec![
NoteEdit::Title { NoteEdit::RemindAt {
value: "first".to_string(), value: "2026-01-01T00:00:00Z".to_string(),
}, },
NoteEdit::ClearTitle, NoteEdit::ClearRemindAt,
]); ]);
assert!(patch["title"].is_null()); assert!(patch["remind_at"].is_null());
} }
} }
+63
View File
@@ -73,6 +73,43 @@ entirely on `ci-python:3.14`.
`…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use `…/actions/artifacts/{id}/zip`. Note the workstation has no `unzip` — use
`python3 -m zipfile -e`. `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 ## Desktop (Tauri) lane — separate workflow
The Tauri desktop client (`desktop/`) builds in its own 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 take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
whenever the space is wanted. 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` **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 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, literal — copying that shape caused one of four consecutive fmt-only CI failures,
which is what this whole section exists to prevent. 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 ## The desktop lockfile
`Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for `Cargo.lock` is **committed** at the workspace root, per Cargo's own guidance for
+7 -54
View File
@@ -1,38 +1,14 @@
//! Deriving `[[wiki-links]]` and `#tags` from a note's body — the local mirror of //! Deriving `#tags` from a note's body — the local mirror of what the server computes
//! what the server computes on save. Pure string scanning (no regex dependency), //! on save. Pure string scanning (no regex dependency), kept in lockstep with the
//! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts): //! frontend's inline rules (see frontend notes/markdown.ts):
//! //!
//! - `[[link]]`: `[[` … `]]` with no brackets inside, inner text trimmed. Used to
//! compute backlinks at query time (links are derived, never stored/synced).
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first). //! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
//! On save these become labels attached with `via_tag = true`. //! On save these become labels attached with `via_tag = true`.
//! //!
//! Both dedupe case-insensitively, preserving first-seen order. //! Dedupes case-insensitively, preserving first-seen order.
//!
/// Extract the trimmed inner text of every `[[wiki-link]]` in `body`. //! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
pub fn extract_links(body: &str) -> Vec<String> { //! capture-and-recall surface, and a linking system is organization.
let bytes = body.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
if let Some(rel) = body[i + 2..].find("]]") {
let inner = &body[i + 2..i + 2 + rel];
// Mirror the frontend's `[^[\]]+`: no stray brackets inside.
if !inner.contains('[') && !inner.contains(']') {
let t = inner.trim();
if !t.is_empty() {
push_unique(&mut out, t);
}
}
i += 2 + rel + 2;
continue;
}
}
i += 1;
}
out
}
/// Extract every `#tag` name (without the leading `#`) from `body`. /// Extract every `#tag` name (without the leading `#`) from `body`.
pub fn extract_tags(body: &str) -> Vec<String> { pub fn extract_tags(body: &str) -> Vec<String> {
@@ -73,28 +49,6 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn links_basic_and_trim() {
assert_eq!(
extract_links("see [[ Alpha ]] and [[Beta]]"),
vec!["Alpha", "Beta"]
);
}
#[test]
fn links_dedupe_case_insensitive_first_seen() {
assert_eq!(extract_links("[[Note]] then [[note]] again"), vec!["Note"]);
}
#[test]
fn links_ignore_malformed_and_nested_brackets() {
assert_eq!(
extract_links("[[a[b]] [[]] [ [x] ] plain"),
Vec::<String>::new()
);
assert_eq!(extract_links("[[ok]] [[a]b]]"), vec!["ok"]);
}
#[test] #[test]
fn tags_basic() { fn tags_basic() {
assert_eq!( assert_eq!(
@@ -116,7 +70,6 @@ mod tests {
#[test] #[test]
fn empty_body() { fn empty_body() {
assert!(extract_links("").is_empty());
assert!(extract_tags("").is_empty()); assert!(extract_tags("").is_empty());
} }
} }
+3 -17
View File
@@ -8,13 +8,12 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize)] #[derive(Serialize)]
pub struct Note { pub struct Note {
pub id: String, pub id: String,
pub title: Option<String>, /// The note's NAME: its first non-blank body line, else its first checklist item.
/// title if set, else the note's first body line — always present, so body-only /// Always present, so every note has something to be called. Derived at read time,
/// notes are still nameable and `[[link]]`-able. Derived, never stored. /// never stored.
pub display_title: String, pub display_title: String,
pub body: String, pub body: String,
pub color: String, pub color: String,
pub kind: String,
pub position: i64, pub position: i64,
pub pinned: bool, pub pinned: bool,
pub archived: bool, pub archived: bool,
@@ -72,7 +71,6 @@ pub struct LinkPreview {
#[derive(Serialize)] #[derive(Serialize)]
pub struct NoteRevision { pub struct NoteRevision {
pub id: String, pub id: String,
pub title: Option<String>,
pub body: String, pub body: String,
pub created_at: Option<String>, pub created_at: Option<String>,
} }
@@ -94,12 +92,6 @@ pub struct TitleEntry {
pub title: String, pub title: String,
} }
#[derive(Serialize)]
pub struct Backlink {
pub id: String,
pub title: String,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct SavedFilter { pub struct SavedFilter {
pub id: String, pub id: String,
@@ -135,15 +127,11 @@ fn default_color() -> String {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct NoteCreateInput { pub struct NoteCreateInput {
#[serde(default)]
pub title: String,
#[serde(default)] #[serde(default)]
pub body: String, pub body: String,
#[serde(default = "default_color")] #[serde(default = "default_color")]
pub color: String, pub color: String,
#[serde(default)] #[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub items: Option<Vec<String>>, pub items: Option<Vec<String>>,
} }
@@ -168,8 +156,6 @@ pub struct Facets {
#[serde(default)] #[serde(default)]
pub color: Option<String>, pub color: Option<String>,
#[serde(default)] #[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub has_reminder: Option<bool>, pub has_reminder: Option<bool>,
+8 -8
View File
@@ -93,8 +93,8 @@ mod tests {
let when = Utc::now() - age; let when = Utc::now() - age;
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
conn.execute( conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) "INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)", VALUES (?1, 'B', ?2, ?2, 1, ?2)",
rusqlite::params![id, stamped], rusqlite::params![id, stamped],
) )
.expect("insert"); .expect("insert");
@@ -149,8 +149,8 @@ mod tests {
fn an_untrashed_note_is_never_swept() { fn an_untrashed_note_is_never_swept() {
let conn = db(); let conn = db();
conn.execute( conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed) "INSERT INTO notes (id, body, created_at, updated_at, trashed)
VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)", VALUES ('live', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
[], [],
) )
.expect("insert"); .expect("insert");
@@ -163,8 +163,8 @@ mod tests {
// "Age unknown" must never resolve to "delete it". // "Age unknown" must never resolve to "delete it".
let conn = db(); let conn = db();
conn.execute( conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) "INSERT INTO notes (id, 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')", VALUES ('weird', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
[], [],
) )
.expect("insert"); .expect("insert");
@@ -179,8 +179,8 @@ mod tests {
let conn = db(); let conn = db();
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339(); let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
conn.execute( conn.execute(
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at) "INSERT INTO notes (id, body, created_at, updated_at, trashed, trashed_at)
VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)", VALUES ('server', 'B', ?1, ?1, 1, ?1)",
rusqlite::params![stamped], rusqlite::params![stamped],
) )
.expect("insert"); .expect("insert");
+31 -2
View File
@@ -1,7 +1,8 @@
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an //! Local SQLite schema + migrations. The schema mirrors the note/label model so an
//! offline note can later sync 1:1 with the server. Each syncable row carries local //! offline note can later sync 1:1 with the server. Each syncable row carries local
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7); //! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
//! `[[links]]` are NOT stored (derived at query time), matching docs/sync.md. //! `#tags` are NOT stored as such (derived at query time into labels), matching
//! docs/sync.md.
//! //!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change. //! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
@@ -13,7 +14,7 @@ CREATE TABLE notes (
title TEXT, title TEXT,
body TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '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, position INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
@@ -159,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. /// Bring the database up to the latest schema. Idempotent.
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> { pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute_batch("PRAGMA foreign_keys = ON;")?;
@@ -183,5 +204,13 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V5)?; conn.execute_batch(SCHEMA_V5)?;
conn.execute_batch("PRAGMA user_version = 5;")?; 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(()) Ok(())
} }
+66 -151
View File
@@ -24,30 +24,26 @@ fn new_id() -> String {
Uuid::new_v4().to_string() Uuid::new_v4().to_string()
} }
/// title if non-empty, else the first non-blank body line — always a string. /// The note's NAME: its first non-blank body line, else its first checklist item.
fn display_title(title: Option<&str>, body: &str) -> String { ///
if let Some(t) = title { /// Mirrors `derive_display_title` in the server's notes/helpers.py — one rule written
let t = t.trim(); /// twice, and they have to agree or a synced note is called different things on either
if !t.is_empty() { /// side of the wire.
return t.to_string(); ///
} /// 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() items
.map(str::trim) .iter()
.find(|l| !l.is_empty()) .map(|i| i.text.trim())
.find(|t| !t.is_empty())
.unwrap_or("") .unwrap_or("")
.to_string() .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 { fn escape_like(s: &str) -> String {
s.replace('\\', "\\\\") s.replace('\\', "\\\\")
.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> { fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row( 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", FROM notes WHERE id = ?1",
[id], [id],
|r| { |r| {
let title: Option<String> = r.get(1)?; let body: String = r.get(1)?;
let body: String = r.get(2)?;
let dt = display_title(title.as_deref(), &body);
Ok(Note { Ok(Note {
id: r.get(0)?, id: r.get(0)?,
title, display_title: String::new(), // filled below — it may need a query
display_title: dt,
body, body,
color: r.get(3)?, color: r.get(2)?,
kind: r.get(4)?, position: r.get(3)?,
position: r.get(5)?, pinned: r.get(4)?,
pinned: r.get(6)?, archived: r.get(5)?,
archived: r.get(7)?, trashed: r.get(6)?,
trashed: r.get(8)?, deleted_at: r.get(11)?,
deleted_at: r.get(13)?, remind_at: r.get(7)?,
remind_at: r.get(9)?, recurrence: r.get(8)?,
recurrence: r.get(10)?,
labels: Vec::new(), labels: Vec::new(),
items: Vec::new(), items: Vec::new(),
attachments: Vec::new(), attachments: Vec::new(),
previews: Vec::new(), previews: Vec::new(),
created_at: r.get(11)?, created_at: r.get(9)?,
updated_at: r.get(12)?, 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.items = load_items(conn, id)?;
note.attachments = load_attachments(conn, id)?; note.attachments = load_attachments(conn, id)?;
note.previews = load_previews(conn, id)?; note.previews = load_previews(conn, id)?;
// After the items, because a body-only-empty note is named by its first one.
note.display_title = display_title(&note.body, &note.items);
Ok(note) Ok(note)
} }
@@ -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(f) = &q.facets {
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) { 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)); let pat = format!("%{}%", escape_like(text));
binds.push(pat.clone()); binds.push(pat.clone());
binds.push(pat); binds.push(pat);
@@ -277,10 +271,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
sql.push_str(" AND color = ?"); sql.push_str(" AND color = ?");
binds.push(c.to_string()); 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) { if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL"); 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>> { pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?; // Names come from `load_note` rather than from a bare row, because a note whose
let rows = stmt.query_map([], |r| { // body is empty is named by its first checklist item — which a row here doesn't
let title: Option<String> = r.get(1)?; // have. The command palette reads this; correctness beats one query per note at
let body: String = r.get(2)?; // personal scale.
Ok(TitleEntry { let ids: Vec<String> = {
id: r.get(0)?, let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?;
title: display_title(title.as_deref(), &body), 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: note.id,
title: note.display_title,
})
}) })
})?; .collect()
rows.collect()
} }
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> { pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
let pat = format!("%{}%", escape_like(q)); let pat = format!("%{}%", escape_like(q));
let ids: Vec<String> = { let ids: Vec<String> = {
let mut stmt = conn.prepare( 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))?; let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()? rows.collect::<rusqlite::Result<Vec<String>>>()?
@@ -350,77 +348,20 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
ids.iter().map(|id| load_note(conn, id)).collect() ids.iter().map(|id| load_note(conn, id)).collect()
} }
pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result<Vec<Backlink>> {
let target: String = {
let (t, b): (Option<String>, String) =
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
display_title(t.as_deref(), &b)
};
if target.is_empty() {
return Ok(Vec::new());
}
let mut stmt =
conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0 AND id != ?1")?;
let rows = stmt.query_map([id], |r| {
let nid: String = r.get(0)?;
let t: Option<String> = r.get(1)?;
let b: String = r.get(2)?;
Ok((nid, t, b))
})?;
let mut out = Vec::new();
for row in rows {
let (nid, t, b) = row?;
if derive::extract_links(&b)
.iter()
.any(|l| l.eq_ignore_ascii_case(&target))
{
out.push(Backlink {
id: nid,
title: display_title(t.as_deref(), &b),
});
}
}
Ok(out)
}
pub fn link_search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<TitleEntry>> {
let ql = q.trim().to_lowercase();
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
let rows = stmt.query_map([], |r| {
let id: String = r.get(0)?;
let t: Option<String> = r.get(1)?;
let b: String = r.get(2)?;
Ok((id, t, b))
})?;
let mut out = Vec::new();
for row in rows {
let (id, t, b) = row?;
let dt = display_title(t.as_deref(), &b);
if ql.is_empty() || dt.to_lowercase().contains(&ql) {
out.push(TitleEntry { id, title: dt });
}
}
Ok(out)
}
// ---- notes: write ----------------------------------------------------------- // ---- notes: write -----------------------------------------------------------
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> { pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
let id = new_id(); let id = new_id();
let ts = now(); 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( let position: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes", "SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
[], [],
|r| r.get(0), |r| r.get(0),
)?; )?;
conn.execute( conn.execute(
"INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty) "INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)", VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, title, input.body, input.color, kind, position, ts], params![id, input.body, input.color, position, ts],
)?; )?;
if let Some(items) = &input.items { if let Some(items) = &input.items {
for (i, text) in items.iter().enumerate() { for (i, text) in items.iter().enumerate() {
@@ -434,25 +375,12 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
load_note(conn, &id) load_note(conn, &id)
} }
pub fn create_titled(conn: &Connection, title: &str) -> rusqlite::Result<Note> {
let input = NoteCreateInput {
title: title.to_string(),
body: String::new(),
color: "default".to_string(),
kind: None,
items: None,
};
create_note(conn, &input)
}
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> { fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let (title, body): (Option<String>, String) = let body: String =
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| { conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
Ok((r.get(0)?, r.get(1)?))
})?;
conn.execute( conn.execute(
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", "INSERT INTO note_revisions (id, note_id, body, created_at) VALUES (?1, ?2, ?3, ?4)",
params![new_id(), id, title, body, now()], params![new_id(), id, body, now()],
)?; )?;
Ok(()) Ok(())
} }
@@ -463,20 +391,13 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
.as_object() .as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?; .ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit title/body once if either is being changed (version history). // Snapshot the pre-edit body before changing it (version history).
if obj.contains_key("title") || obj.contains_key("body") { if obj.contains_key("body") {
snapshot_revision(conn, id)?; snapshot_revision(conn, id)?;
} }
for (k, v) in obj { for (k, v) in obj {
match k.as_str() { match k.as_str() {
"title" => {
let norm = v.as_str().and_then(normalize_title);
conn.execute(
"UPDATE notes SET title = ?1 WHERE id = ?2",
params![norm, id],
)?;
}
"body" => { "body" => {
let body = v.as_str().unwrap_or(""); let body = v.as_str().unwrap_or("");
conn.execute( conn.execute(
@@ -490,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])?; 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" => { "pinned" => {
if let Some(b) = v.as_bool() { if let Some(b) = v.as_bool() {
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?; conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
@@ -730,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>> { pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
let mut stmt = conn 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| { let rows = stmt.query_map([id], |r| {
Ok(NoteRevision { Ok(NoteRevision {
id: r.get(0)?, id: r.get(0)?,
title: r.get(1)?, body: r.get(1)?,
body: r.get(2)?, created_at: r.get(2)?,
created_at: r.get(3)?,
}) })
})?; })?;
rows.collect() rows.collect()
} }
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> { pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
let (title, body): (Option<String>, String) = conn.query_row( let body: String = conn.query_row(
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2", "SELECT body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
params![rev_id, id], params![rev_id, id],
|r| Ok((r.get(0)?, r.get(1)?)), |r| r.get(0),
)?; )?;
snapshot_revision(conn, id)?; snapshot_revision(conn, id)?;
conn.execute( conn.execute(
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3", "UPDATE notes SET body = ?1 WHERE id = ?2",
params![title, body, id], params![body, id],
)?; )?;
sync_tags(conn, id, &body)?; sync_tags(conn, id, &body)?;
touch(conn, id)?; touch(conn, id)?;
+12 -8
View File
@@ -19,11 +19,11 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The sync wire protocol this client speaks. /// The sync wire protocol this client speaks.
pub const CLIENT_PROTOCOL_VERSION: u32 = 1; pub const CLIENT_PROTOCOL_VERSION: u32 = 2;
/// The oldest server protocol this client can drive — the symmetric half of the /// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`. /// server's `min_client_protocol_version`.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 1; pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 2;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the /// Capabilities without which syncing is meaningless, so their absence BLOCKS the
/// link rather than degrading it. /// link rather than degrading it.
@@ -344,13 +344,17 @@ mod tests {
fn server_info_tolerates_unknown_and_absent_fields() { fn server_info_tolerates_unknown_and_absent_fields() {
// Forward compatibility: a NEWER server sending fields we've never heard of // Forward compatibility: a NEWER server sending fields we've never heard of
// must not break the handshake. // must not break the handshake.
let info: ServerInfo = serde_json::from_str( // Versions come from the constants, not literals: this test is about unknown
r#"{"site_name":"S","sync_protocol_version":1, // FIELDS, and pinning the numbers made it fail the moment the protocol moved
"min_client_protocol_version":1, // 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"], "sync_features":["notes","labels","attachments","tombstones","revisions"],
"some_future_field":{"nested":true}}"#, "some_future_field":{{"nested":true}}}}"#,
) v = CLIENT_PROTOCOL_VERSION,
.expect("unknown fields are ignored"); );
let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored");
assert_eq!(evaluate(&info), Compatibility::Ok); assert_eq!(evaluate(&info), Compatibility::Ok);
} }
+2 -8
View File
@@ -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 // `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. // never changes, and the server's copy is the same value anyway.
conn.execute( 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, trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty) 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 ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
body = excluded.body, body = excluded.body,
color = excluded.color, color = excluded.color,
kind = excluded.kind,
position = excluded.position, position = excluded.position,
pinned = excluded.pinned, pinned = excluded.pinned,
archived = excluded.archived, archived = excluded.archived,
@@ -261,10 +259,8 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
dirty = 0", dirty = 0",
params![ params![
note.id, note.id,
note.title,
note.body, note.body,
note.color, note.color,
note.kind,
note.position, note.position,
note.pinned, note.pinned,
note.archived, note.archived,
@@ -498,10 +494,8 @@ mod tests {
fn note(id: &str, revision: i64) -> wire::Note { fn note(id: &str, revision: i64) -> wire::Note {
wire::Note { wire::Note {
id: id.to_string(), id: id.to_string(),
title: Some("Title".into()),
body: "Body".into(), body: "Body".into(),
color: "default".into(), color: "default".into(),
kind: "text".into(),
position: 0, position: 0,
pinned: false, pinned: false,
archived: false, archived: false,
+13 -27
View File
@@ -62,15 +62,11 @@ pub struct Change {
pub op: &'static str, pub op: &'static str,
pub edited_at: String, pub edited_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>, pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>, pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pinned: Option<bool>, pub pinned: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>, pub archived: Option<bool>,
@@ -99,10 +95,8 @@ impl Change {
id, id,
op: "delete", op: "delete",
edited_at, edited_at,
title: None,
body: None, body: None,
color: None, color: None,
kind: None,
pinned: None, pinned: None,
archived: None, archived: None,
trashed: None, trashed: None,
@@ -200,9 +194,7 @@ fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rus
name: Some(r.get(1)?), name: Some(r.get(1)?),
color: Some(r.get(2)?), color: Some(r.get(2)?),
edited_at: r.get(3)?, edited_at: r.get(3)?,
title: None,
body: None, body: None,
kind: None,
pinned: None, pinned: None,
archived: None, archived: None,
trashed: 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 /// 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. /// field-to-column mapping stays readable at the call site.
struct NoteRow { struct NoteRow {
title: Option<String>,
body: String, body: String,
color: String, color: String,
kind: String,
position: i64, position: i64,
pinned: bool, pinned: bool,
archived: bool, archived: bool,
@@ -253,24 +243,22 @@ struct NoteRow {
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> { fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
conn.query_row( 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 remind_at, recurrence, created_at, updated_at
FROM notes WHERE id = ?1", FROM notes WHERE id = ?1",
params![id], params![id],
|r| { |r| {
Ok(NoteRow { Ok(NoteRow {
title: r.get(0)?, body: r.get(0)?,
body: r.get(1)?, color: r.get(1)?,
color: r.get(2)?, position: r.get(2)?,
kind: r.get(3)?, pinned: r.get::<_, i64>(3)? != 0,
position: r.get(4)?, archived: r.get::<_, i64>(4)? != 0,
pinned: r.get::<_, i64>(5)? != 0, trashed: r.get::<_, i64>(5)? != 0,
archived: r.get::<_, i64>(6)? != 0, remind_at: r.get(6)?,
trashed: r.get::<_, i64>(7)? != 0, recurrence: r.get(7)?,
remind_at: r.get(8)?, created_at: r.get(8)?,
recurrence: r.get(9)?, updated_at: r.get(9)?,
created_at: r.get(10)?,
updated_at: r.get(11)?,
}) })
}, },
) )
@@ -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 // The local `updated_at` IS the client's edit time, which is what the
// server's last-write-wins comparison runs against. // server's last-write-wins comparison runs against.
edited_at: row.updated_at, edited_at: row.updated_at,
title: row.title,
body: Some(row.body), body: Some(row.body),
color: Some(row.color), color: Some(row.color),
kind: Some(row.kind),
pinned: Some(row.pinned), pinned: Some(row.pinned),
archived: Some(row.archived), archived: Some(row.archived),
trashed: Some(row.trashed), trashed: Some(row.trashed),
@@ -535,9 +521,9 @@ mod tests {
fn seed_note(conn: &Connection, id: &str, dirty: i64) { fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute( 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) 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)", '2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
params![id, dirty], params![id, dirty],
) )
-8
View File
@@ -24,13 +24,9 @@ pub struct ChangesPage {
pub struct Note { pub struct Note {
pub id: String, pub id: String,
#[serde(default)] #[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub body: String, pub body: String,
#[serde(default = "default_color")] #[serde(default = "default_color")]
pub color: String, pub color: String,
#[serde(default = "default_kind")]
pub kind: String,
#[serde(default)] #[serde(default)]
pub position: i64, pub position: i64,
#[serde(default)] #[serde(default)]
@@ -157,10 +153,6 @@ fn default_color() -> String {
"default".to_string() "default".to_string()
} }
fn default_kind() -> String {
"text".to_string()
}
fn default_mime() -> String { fn default_mime() -> String {
"application/octet-stream".to_string() "application/octet-stream".to_string()
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "thoughtsync-desktop" name = "thoughtsync-desktop"
version = "0.1.0" version = "0.2.0"
description = "ThoughtSync desktop — local-first Keep-style thought capture" description = "ThoughtSync desktop — local-first Keep-style thought capture"
authors = ["bvandeusen"] authors = ["bvandeusen"]
edition = "2021" edition = "2021"
-24
View File
@@ -67,12 +67,6 @@ pub fn notes_create(input: NoteCreateInput, db: State<'_, Db>) -> Result<Note, S
store::create_note(&conn, &input).map_err(|e| e.to_string()) store::create_note(&conn, &input).map_err(|e| e.to_string())
} }
#[tauri::command]
pub fn notes_create_titled(title: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::create_titled(&conn, &title).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub fn notes_update(id: String, changes: Value, db: State<'_, Db>) -> Result<Note, String> { pub fn notes_update(id: String, changes: Value, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?; let conn = db.0.lock().map_err(|e| e.to_string())?;
@@ -196,24 +190,6 @@ pub fn notes_titles(db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
store::titles(&conn).map_err(|e| e.to_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 notes_backlinks(id: String, db: State<'_, Db>) -> Result<Vec<Backlink>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::backlinks(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_link_search(q: String, db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::link_search(&conn, &q).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> { pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?; let conn = db.0.lock().map_err(|e| e.to_string())?;
-4
View File
@@ -102,7 +102,6 @@ pub fn run() {
commands::local::notes_list, commands::local::notes_list,
commands::local::notes_get, commands::local::notes_get,
commands::local::notes_create, commands::local::notes_create,
commands::local::notes_create_titled,
commands::local::notes_update, commands::local::notes_update,
commands::local::notes_complete_reminder, commands::local::notes_complete_reminder,
commands::local::notes_snooze_reminder, commands::local::notes_snooze_reminder,
@@ -120,9 +119,6 @@ pub fn run() {
commands::local::notes_restore_revision, commands::local::notes_restore_revision,
commands::local::notes_reminders, commands::local::notes_reminders,
commands::local::notes_titles, commands::local::notes_titles,
commands::local::notes_search,
commands::local::notes_backlinks,
commands::local::notes_link_search,
commands::local::labels_list, commands::local::labels_list,
commands::local::labels_create, commands::local::labels_create,
commands::local::labels_rename, commands::local::labels_rename,
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ThoughtSync", "productName": "ThoughtSync",
"mainBinaryName": "thoughtsync", "mainBinaryName": "thoughtsync",
"version": "0.1.0", "version": "0.2.0",
"identifier": "com.fabledsword.thoughtsync", "identifier": "com.fabledsword.thoughtsync",
"build": { "build": {
"frontendDist": "../../frontend/dist", "frontendDist": "../../frontend/dist",
+21 -3
View File
@@ -71,10 +71,28 @@ services:
# Uploaded attachments. /var/thoughtsync is fixed in the app (Config.DATA_DIR), # Uploaded attachments. /var/thoughtsync is fixed in the app (Config.DATA_DIR),
# not configurable — mount it or lose every image on container recreation. # not configurable — mount it or lose every image on container recreation.
- thoughtsync-data:/var/thoughtsync - 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: 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" - "${THOUGHTSYNC_BIND:-0.0.0.0}:${THOUGHTSYNC_PORT:-5000}:5000"
healthcheck: healthcheck:
# python rather than curl: the runtime image is python:3.12-slim and carries no # python rather than curl: the runtime image is python:3.12-slim and carries no
+142
View File
@@ -0,0 +1,142 @@
# Putting ThoughtSync on the public internet
ThoughtSync is built to run on a LAN and works fine there with no ceremony. Exposing
it changes the threat model: anyone can now reach the login form, and any account is
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 five things first
**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.
**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
HTTPS. It looks at `X-Forwarded-Proto`, so the proxy has to set it:
```
# Traefik does this automatically. For nginx:
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Without that header the app assumes plain HTTP and leaves the cookie unmarked — the
conservative choice, since forcing `Secure` on an HTTP install stops the browser from
ever sending the cookie back and silently breaks login.
Once a browser has seen HSTS from your hostname it will refuse plain HTTP there for a
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. 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.
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. 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:
```
docker compose exec -T db pg_dump -U thoughtsync thoughtsync > notes.sql
docker run --rm -v thoughtsync-data:/d -v "$PWD":/out alpine tar czf /out/media.tgz -C /d .
```
## What the app already does
- **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. 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.
- **A failed sign-in takes the same time whether or not the account exists.** No
timing oracle for which emails are registered here.
- **Every response carries a CSP** with `script-src 'self'`, `object-src 'none'` and
`frame-ancestors 'none'`, plus `nosniff`, a referrer policy and a permissions
policy. The app has no inline or third-party scripts, so this costs nothing.
- **Link unfurling is SSRF-hardened.** Every hop is resolved and every resolved
address must be publicly routable before a socket is opened, and the connection is
made to the vetted IP so a rebind between check and connect cannot slip through. A
note containing `http://192.168.1.1/` cannot make your server probe your network.
- **Attachments never render inline unless they are a known raster image.** Anything
else — an SVG, an HTML file — is served `Content-Disposition: attachment`, so a file
on a note shared with you can't run script in your session.
- **Session cookies are `HttpOnly` and `SameSite=Lax`**, which is also what stands in
for CSRF protection: a `Lax` cookie is not sent on a cross-site POST.
## What it does not do
Know these before you decide who gets an account.
- **No email verification and no password reset.** `email_verified` exists on the user
row and nothing sets it. A forgotten password needs a hand on the database.
- **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 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.
## The Android client
The app allows plain HTTP so a self-hosted server on a LAN is usable at all — Android
blocks cleartext by default from API 28, and `http://192.168.1.10:8000` is exactly the
case ThoughtSync is built for. Over the public internet, link the phone to the
**HTTPS** hostname. The sync screen shows a warning before any credential field
whenever the address it probed was `http://`; on a public network that warning means
what it says.
The APK the server hands out is signed with the project release key, and the in-app
updater installs over the existing app only because the signature matches. A build
from anywhere else will not install over it.
+4 -4
View File
@@ -122,9 +122,9 @@ as `?since=`. `since=0` (or absent) is a **full initial sync**.
so pulling the note re-syncs the whole thing. so pulling the note re-syncs the whole thing.
- **Label** — the label *catalog* (name + color) syncs as its own entity so a - **Label** — the label *catalog* (name + color) syncs as its own entity so a
rename/recolor/delete propagates independently of notes. rename/recolor/delete propagates independently of notes.
- **Derived, NOT synced:** `[[wiki-links]]` and `#tags` are parsed from the note - **Derived, NOT synced:** `#tags` are parsed from the note body. Clients recompute
body. Clients recompute them locally; the server recomputes them on push. They them locally; the server recomputes them on push. They never travel over the wire.
never travel over the wire. (`[[wiki-links]]` were derived the same way until they were removed — note 2897.)
- **Attachment blobs** sync by id over the existing upload/download routes (see - **Attachment blobs** sync by id over the existing upload/download routes (see
Attachments below); only their metadata rides the delta feed. Attachments below); only their metadata rides the delta feed.
@@ -201,7 +201,7 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
- **Whole-note semantics.** A note upsert carries the client's *full* current - **Whole-note semantics.** A note upsert carries the client's *full* current
state (not a partial patch) — the server overwrites all scalar fields, replaces state (not a partial patch) — the server overwrites all scalar fields, replaces
items, and sets manual label memberships from `label_ids` (tag-sourced labels items, and sets manual label memberships from `label_ids` (tag-sourced labels
are re-derived from the body). `[[links]]`/`#tags` are recomputed server-side. are re-derived from the body). `#tags` are recomputed server-side.
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with - **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
`trashed: true`. `trashed: true`.
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name, - **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <!-- `viewport-fit=cover` is what makes `env(safe-area-inset-*)` resolve to
anything other than 0. It opts the page into drawing under the notch and the
gesture bar, so it is only safe alongside the padding that keeps content out
of them — see style.css (sides), the sticky header (top) and the board
(bottom). The two halves ship together, deliberately: turning this on alone
is worse than leaving it off (task 2706). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#F5C518" /> <meta name="theme-color" content="#F5C518" />
<meta <meta
name="description" name="description"
+2 -6
View File
@@ -6,7 +6,7 @@
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning // parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
// yet (account auth, device linking, attachment upload, URL unfurl, file import) — // yet (account auth, device linking, attachment upload, URL unfurl, file import) —
// those reject with a clear message rather than silently failing; the board, editor, // 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 { invoke } from "../desktop/bridge";
import type { Note, NoteRevision } from "../stores/notes"; import type { Note, NoteRevision } from "../stores/notes";
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles"; import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session"; import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config"; import type { PublicConfig } from "../stores/config";
import type { Backlink, DeviceToken, ImportResult, Repo } from "./repo"; import type { DeviceToken, ImportResult, Repo } from "./repo";
const NEEDS_SERVER = "That's not available offline — connect a server to use it."; const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
@@ -51,7 +51,6 @@ export const local: Repo = {
list: (query) => invoke<Note[]>("notes_list", { query }), list: (query) => invoke<Note[]>("notes_list", { query }),
get: (id) => invoke<Note>("notes_get", { id }), get: (id) => invoke<Note>("notes_get", { id }),
create: (input) => invoke<Note>("notes_create", { input }), create: (input) => invoke<Note>("notes_create", { input }),
createTitled: (title) => invoke<Note>("notes_create_titled", { title }),
update: (id, changes) => invoke<Note>("notes_update", { id, changes }), update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }), completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }), snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
@@ -72,9 +71,6 @@ export const local: Repo = {
restoreRevision: (id, revId) => invoke<Note>("notes_restore_revision", { id, revId }), restoreRevision: (id, revId) => invoke<Note>("notes_restore_revision", { id, revId }),
reminders: () => invoke<Note[]>("notes_reminders"), reminders: () => invoke<Note[]>("notes_reminders"),
titles: () => invoke<TitleEntry[]>("notes_titles"), titles: () => invoke<TitleEntry[]>("notes_titles"),
search: (q) => invoke<Note[]>("notes_search", { q }),
backlinks: (id) => invoke<Backlink[]>("notes_backlinks", { id }),
linkSearch: (q) => invoke<TitleEntry[]>("notes_link_search", { q }),
}, },
savedFilters: { savedFilters: {
+2 -12
View File
@@ -10,7 +10,7 @@
// stays in the stores — the repo is data access only. // stays in the stores — the repo is data access only.
import type { NoteColor } from "../notes/colors"; 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 { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters"; import type { SavedFilter } from "../stores/savedFilters";
import type { Device } from "../stores/devices"; import type { Device } from "../stores/devices";
@@ -32,16 +32,14 @@ export interface NoteListQuery {
} }
export interface NoteCreateInput { export interface NoteCreateInput {
title: string;
body: string; body: string;
color: NoteColor; color: NoteColor;
kind?: NoteKind;
items?: string[]; items?: string[];
} }
// The mutable subset of a note (PATCH /api/notes/:id). // The mutable subset of a note (PATCH /api/notes/:id).
export type NoteChanges = Partial< 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 { export interface ChecklistItemChanges {
@@ -49,10 +47,6 @@ export interface ChecklistItemChanges {
checked?: boolean; checked?: boolean;
} }
export interface Backlink {
id: string;
title: string;
}
export interface ImportResult { export interface ImportResult {
source: string; source: string;
@@ -97,7 +91,6 @@ export interface NotesRepo {
list(query: NoteListQuery): Promise<Note[]>; list(query: NoteListQuery): Promise<Note[]>;
get(id: string): Promise<Note>; get(id: string): Promise<Note>;
create(input: NoteCreateInput): Promise<Note>; create(input: NoteCreateInput): Promise<Note>;
createTitled(title: string): Promise<Note>;
update(id: string, changes: NoteChanges): Promise<Note>; update(id: string, changes: NoteChanges): Promise<Note>;
completeReminder(id: string): Promise<Note>; completeReminder(id: string): Promise<Note>;
snoozeReminder(id: string, minutes: number): Promise<Note>; snoozeReminder(id: string, minutes: number): Promise<Note>;
@@ -118,9 +111,6 @@ export interface NotesRepo {
restoreRevision(id: string, revId: string): Promise<Note>; restoreRevision(id: string, revId: string): Promise<Note>;
reminders(): Promise<Note[]>; reminders(): Promise<Note[]>;
titles(): Promise<TitleEntry[]>; titles(): Promise<TitleEntry[]>;
search(q: string): Promise<Note[]>;
backlinks(id: string): Promise<Backlink[]>;
linkSearch(q: string): Promise<TitleEntry[]>;
} }
export interface SavedFiltersRepo { export interface SavedFiltersRepo {
-7
View File
@@ -13,7 +13,6 @@ import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session"; import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config"; import type { PublicConfig } from "../stores/config";
import type { import type {
Backlink,
DeviceToken, DeviceToken,
ImportResult, ImportResult,
NoteChanges, NoteChanges,
@@ -33,7 +32,6 @@ function notesQuery(q: NoteListQuery): string {
for (const id of q.facets?.label ?? []) if (id) params.append("label", id); 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?.q) params.set("q", q.facets.q);
if (q.facets?.color) params.set("color", q.facets.color); 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_reminder) params.set("has_reminder", "true");
if (q.facets?.has_attachment) params.set("has_attachment", "true"); if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after); if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
@@ -80,7 +78,6 @@ export const rest: Repo = {
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes, list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
get: (id) => api.get<Note>(`/api/notes/${id}`), get: (id) => api.get<Note>(`/api/notes/${id}`),
create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input), create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input),
createTitled: (title) => api.post<Note>("/api/notes", { title, body: "" }),
update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes), update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes),
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`), completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }), snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
@@ -102,10 +99,6 @@ export const rest: Repo = {
restoreRevision: (id, revId) => api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`), restoreRevision: (id, revId) => api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`),
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes, reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles, 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,
backlinks: async (id) => (await api.get<{ backlinks: Backlink[] }>(`/api/notes/${id}/backlinks`)).backlinks,
linkSearch: async (q) =>
(await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`)).results,
}, },
savedFilters: { savedFilters: {
+87 -32
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; 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 { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore } from "../stores/labels";
@@ -49,7 +49,6 @@ const shortcuts = [
{ label: "New note (or just start typing)", keys: ["Enter", "c"] }, { label: "New note (or just start typing)", keys: ["Enter", "c"] },
{ label: "Search", keys: ["/"] }, { label: "Search", keys: ["/"] },
{ label: "Go to Board", keys: ["g", "b"] }, { label: "Go to Board", keys: ["g", "b"] },
{ label: "Go to Graph", keys: ["g", "g"] },
{ label: "Go to Reminders", keys: ["g", "r"] }, { label: "Go to Reminders", keys: ["g", "r"] },
{ label: "Go to Timeline", keys: ["g", "t"] }, { label: "Go to Timeline", keys: ["g", "t"] },
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] }, { label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
@@ -121,11 +120,6 @@ function onKeydown(e: KeyboardEvent) {
void router.push("/"); void router.push("/");
return; return;
} }
if (e.key === "g") {
e.preventDefault();
void router.push("/graph");
return;
}
if (e.key === "r") { if (e.key === "r") {
e.preventDefault(); e.preventDefault();
void router.push("/reminders"); void router.push("/reminders");
@@ -183,21 +177,48 @@ function labelDot(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default; 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) { function onSearch(value: string) {
searchText.value = value; searchText.value = value;
clearTimeout(searchTimer); clearTimeout(searchTimer);
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
const q = searchText.value.trim(); const q = searchText.value.trim();
if (q) router.push({ name: "search", query: { q } }); const onBoard = BOARD_ROUTES.has(String(route.name));
else if (route.name === "search") router.push("/"); const query: LocationQueryRaw = onBoard ? { ...route.query } : {};
if (q) query.q = q;
else delete query.q;
void router.push({ path: onBoard ? route.path : "/", query });
}, 250); }, 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( watch(
() => route.name, () => route.name,
(name) => { () => {
if (name !== "search") searchText.value = "";
drawer.value = false; drawer.value = false;
}, },
); );
@@ -206,8 +227,8 @@ watch(
* What to call the lens currently in view. * What to call the lens currently in view.
* *
* Keyed off the route name rather than each view declaring its own title, so the * 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, reminders and graph each had their own h1). * 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 * A label lens is named by the label itself — "Groceries" is what the user came
* looking for; "Label" would tell them nothing they didn't already know. * looking for; "Label" would tell them nothing they didn't already know.
@@ -218,14 +239,10 @@ const lensName = computed<string>(() => {
return "Archive"; return "Archive";
case "trash": case "trash":
return "Trash"; return "Trash";
case "search":
return "Search";
case "timeline": case "timeline":
return "Timeline"; return "Timeline";
case "reminders": case "reminders":
return "Reminders"; return "Reminders";
case "graph":
return "Graph";
case "label": case "label":
// The store may not have loaded yet on a deep link; fall back rather than // The store may not have loaded yet on a deep link; fall back rather than
// flashing an empty slot. // flashing an empty slot.
@@ -252,10 +269,18 @@ async function signOut() {
> >
Skip to notes Skip to notes
</a> </a>
<!-- `pt-[env(safe-area-inset-top)]`: with `viewport-fit=cover` the page draws
under the status bar / notch, and this is the one box that sits there a
sticky header pinned to y=0. Resolves to 0 wherever there is no cutout, so
it costs nothing on a desktop or a flat-topped phone. -->
<header <header
class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90" class="sticky top-0 z-20 border-b border-neutral-200 bg-neutral-50/90 pt-[env(safe-area-inset-top,0px)] backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/90"
> >
<div class="flex items-center gap-3 px-4 py-3"> <!-- Wraps, so the search field can take a line of its own on a narrow screen.
In one row it was sharing ~360px with a menu button, the logo, the lens
name and four icons, which left every one of them truncated the lens read
as "N…" and the search box as an empty pill. -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
<button <button
type="button" type="button"
class="icon-btn sm:hidden" class="icon-btn sm:hidden"
@@ -286,7 +311,7 @@ async function signOut() {
page you navigated to — so it sits in the bar that never moves, beside 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 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 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"> <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 <!-- 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 hidden on narrow screens. There, the lens name simply takes the space
@@ -295,19 +320,24 @@ async function signOut() {
<span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span> <span class="truncate font-medium text-neutral-600 dark:text-neutral-300">{{ lensName }}</span>
</span> </span>
<div class="flex flex-1 justify-center"> <!-- `order-last w-full` drops this onto its own line below sm (a full-width
flex item forces the wrap); from sm it returns to the middle of the row.
ONE input either way, moved by CSS rather than duplicated the `/`
shortcut focuses `searchInput`, and two of those would be one ref too
many. -->
<div class="order-last flex w-full justify-center sm:order-none sm:w-auto sm:flex-1">
<input <input
ref="searchInput" ref="searchInput"
:value="searchText" :value="searchText"
type="search" type="search"
placeholder="Search notes…" placeholder="Search notes…"
aria-label="Search notes" aria-label="Search notes"
class="w-full max-w-md 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" class="w-full max-w-md rounded-lg border border-neutral-300 bg-white px-3 py-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-brand sm:py-1.5 sm:text-sm dark:border-neutral-700 dark:bg-neutral-900"
@input="onSearch(($event.target as HTMLInputElement).value)" @input="onSearch(($event.target as HTMLInputElement).value)"
/> />
</div> </div>
<div class="flex shrink-0 items-center gap-3"> <div class="ml-auto flex shrink-0 items-center gap-1 sm:ml-0 sm:gap-3">
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 rounded-lg bg-brand px-2.5 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand" class="inline-flex items-center gap-1 rounded-lg bg-brand px-2.5 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@@ -341,7 +371,7 @@ async function signOut() {
<RouterLink <RouterLink
v-if="!desktopApp" v-if="!desktopApp"
to="/account" to="/account"
class="icon-btn" class="icon-btn hidden sm:inline-flex"
title="Linked devices" title="Linked devices"
aria-label="Linked devices" aria-label="Linked devices"
> >
@@ -350,7 +380,7 @@ async function signOut() {
<RouterLink <RouterLink
v-if="session.user?.is_admin" v-if="session.user?.is_admin"
to="/settings" to="/settings"
class="icon-btn" class="icon-btn hidden sm:inline-flex"
title="Settings" title="Settings"
aria-label="Settings" aria-label="Settings"
> >
@@ -364,7 +394,7 @@ async function signOut() {
<button <button
v-if="!desktopApp" v-if="!desktopApp"
type="button" type="button"
class="icon-btn" class="icon-btn hidden sm:inline-flex"
title="Sign out" title="Sign out"
aria-label="Sign out" aria-label="Sign out"
@click="signOut" @click="signOut"
@@ -383,16 +413,13 @@ async function signOut() {
@click="drawer = false" @click="drawer = false"
></div> ></div>
<aside <aside
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 dark:border-neutral-800 dark:bg-neutral-950" class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
:class="drawer ? 'translate-x-0' : ''" :class="drawer ? 'translate-x-0' : ''"
> >
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false"> <nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''"> <RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
<Icon name="note" /> Notes <Icon name="note" /> Notes
</RouterLink> </RouterLink>
<RouterLink to="/graph" class="nav-link" :class="route.name === 'graph' ? 'nav-link-active' : ''">
<Icon name="graph" /> Graph
</RouterLink>
<div class="mt-3 flex items-center justify-between px-3 pb-1"> <div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span> <span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
@@ -469,6 +496,34 @@ async function signOut() {
<Icon name="download" /> Export <Icon name="download" /> Export
</a> </a>
<ImportNotes /> <ImportNotes />
<!-- Account, settings and sign-out, for the screens where they are NOT in
the header. Four icons plus a search field never fit one phone-width
row, and the header is the wrong place to lose: it holds the only way
back to the board. Here they get room to be named instead of guessed
at from a glyph.
Hidden from sm up, where the header carries them again so they are
in exactly one place at any width, never both. -->
<div
v-if="!desktopApp"
class="mt-3 flex flex-col gap-0.5 border-t border-neutral-200 pt-3 sm:hidden dark:border-neutral-800"
>
<p class="truncate px-3 pb-1 text-xs text-neutral-400">{{ session.user?.display_name }}</p>
<RouterLink to="/account" class="nav-link" :class="route.name === 'account' ? 'nav-link-active' : ''">
<Icon name="device" /> Linked devices
</RouterLink>
<RouterLink
v-if="session.user?.is_admin"
to="/settings"
class="nav-link"
:class="route.name === 'settings' ? 'nav-link-active' : ''"
>
<Icon name="settings" /> Settings
</RouterLink>
<button type="button" class="nav-link w-full text-left" @click="signOut">
<Icon name="logout" /> Sign out
</button>
</div>
</nav> </nav>
</aside> </aside>
@@ -481,7 +536,7 @@ async function signOut() {
BoardView, so keying on the route would remount it blanking the board BoardView, so keying on the route would remount it blanking the board
and refetching, which is precisely the page-change feeling this is meant and refetching, which is precisely the page-change feeling this is meant
to remove. Unkeyed, Vue only transitions when the component TYPE changes to remove. Unkeyed, Vue only transitions when the component TYPE changes
(board search timeline graph), 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. --> lenses stays an in-place reflow that NoteGrid animates. -->
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none"> <main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
@@ -42,7 +42,6 @@ const commands = computed<Row[]>(() => {
const list: Row[] = [ const list: Row[] = [
{ id: "cmd:new", label: "New note", hint: "Action", run: compose }, { id: "cmd:new", label: "New note", hint: "Action", run: compose },
{ id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") }, { id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") },
{ id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") },
{ id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") }, { id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
{ id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") }, { id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
{ id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") }, { id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") },
+2 -26
View File
@@ -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 { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: text search + color + labels + has-reminder // A dead-simple facet bar over the board: color + labels + has-reminder
// + has-attachment + kind + created-date range. The URL query IS the state, so a // + 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. // filtered board is a shareable lens and a saved view is just a link.
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -35,9 +35,6 @@ function clearAll() {
function setColor(c: NoteColor) { function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c }); 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) { function toggleLabel(id: string) {
const cur = facets.value.label ?? []; const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]; 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 }); 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) { function onFrom(e: Event) {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
patch({ created_after: v ? `${v}T00:00:00` : undefined }); 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" v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800" 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"> <div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span> <span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button <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"> <button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
Has attachment Has attachment
</button> </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>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
-1
View File
@@ -16,7 +16,6 @@ const paths: Record<string, string> = {
check: '<path d="M20 6 9 17l-5-5"/>', check: '<path d="M20 6 9 17l-5-5"/>',
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>', checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>', image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>', bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>', grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>', calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
+31 -7
View File
@@ -1,12 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LinkPreview } from "../stores/notes"; 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 }>(); defineEmits<{ (e: "remove"): void }>();
</script> </script>
<template> <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 <a
:href="preview.url" :href="preview.url"
target="_blank" target="_blank"
@@ -19,16 +31,28 @@ defineEmits<{ (e: "remove"): void }>();
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" 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"> <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 text-[11px] uppercase tracking-wide text-neutral-400"> <p
v-if="preview.site_name"
class="truncate uppercase tracking-wide text-neutral-400"
:class="compact ? 'text-[10px]' : 'text-[11px]'"
>
{{ preview.site_name }} {{ preview.site_name }}
</p> </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 }} {{ preview.title || preview.url }}
</p> </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 }} {{ preview.description }}
</p> </p>
</div> </div>
+4 -29
View File
@@ -1,42 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from "vue-router";
import { useNotesStore } from "../stores/notes";
import { useTitlesStore } from "../stores/titles";
import type { InlineToken } from "../notes/markdown"; import type { InlineToken } from "../notes/markdown";
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that.
defineProps<{ tokens: InlineToken[] }>(); defineProps<{ tokens: InlineToken[] }>();
const router = useRouter();
const notes = useNotesStore();
const titles = useTitlesStore();
// A [[wiki-link]] on the card: resolve the title and open the target note (creating
// it first if it doesn't exist), via the board's ?open=<id> mechanism.
async function follow(title: string) {
await titles.load();
let hit = titles.resolve(title);
if (!hit) {
const created = await notes.createTitled(title);
await titles.reload();
hit = { id: created.id, title: created.title ?? title };
}
void router.push({ path: "/", query: { open: hit.id } });
}
</script> </script>
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing <!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
spaces are preserved and no extra spaces are introduced. --> spaces are preserved and no extra spaces are introduced. -->
<template <template
><template v-for="(t, i) in tokens" :key="i" ><template v-for="(t, i) in tokens" :key="i"
><span ><strong v-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
v-if="t.type === 'link'"
role="link"
tabindex="0"
class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
@click.stop="follow(t.value)"
@keydown.enter.stop.prevent="follow(t.value)"
>{{ t.value }}</span
><strong v-else-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
><em v-else-if="t.type === 'italic'">{{ t.value }}</em ><em v-else-if="t.type === 'italic'">{{ t.value }}</em
><code ><code
v-else-if="t.type === 'code'" v-else-if="t.type === 'code'"
+180 -132
View File
@@ -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 firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
const otherAttachments = computed(() => props.note.attachments.filter((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); const root = ref<HTMLElement | null>(null);
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a // --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
@@ -186,27 +226,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
]" ]"
:data-note-id="note.id" :data-note-id="note.id"
> >
<!-- Drag handle: reorder is gated behind this grip so a normal click or tap
never starts a drag. Board views only (reorderable). Still a pointer-only
affordance there is no keyboard equivalent, hence tabindex="-1".
`touch-none` hands the gesture to us instead of the browser's scrolling,
and `hover-reveal` keeps the grip on screen where hovering is impossible
(it is now reachable there, which it was not under native drag-and-drop). -->
<button
v-if="canDrag()"
type="button"
tabindex="-1"
class="hover-reveal pointer-events-none absolute left-1.5 top-1.5 z-10 flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
title="Drag to reorder"
aria-label="Drag to reorder"
@pointerdown="onGripDown"
@pointermove="onGripMove"
@pointerup="onGripUp"
@pointercancel="onGripCancel"
>
<Icon name="grip" />
</button>
<img <img
v-if="firstImage" v-if="firstImage"
:src="firstImage.url" :src="firstImage.url"
@@ -227,43 +246,46 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</span> </span>
</div> </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 <!-- One render path: every note is a body plus, maybe, checkable items.
focusable div; text notes keep a semantic button. --> A focusable div rather than a <button>, because a checklist nests interactive
<template v-if="note.kind === 'list'"> controls and those cannot live inside a button and the card is the same
<div shape whether or not it happens to carry items today. -->
role="button" <div
tabindex="0" role="button"
class="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand" tabindex="0"
@click="emit('open', note)" class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
@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>
</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)" @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"> <!-- A lone URL renders as its preview and nothing else. Until the fetch lands
{{ note.title }} or if it never does the URL itself stands in, so the card is never
</h3> blank and the link is never unreachable. -->
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300"> <LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<MarkdownText :text="note.body" /> <div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" />
</div> </div>
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400"> <p
v-if="!note.body && !note.items.length && !note.attachments.length"
class="text-sm italic text-neutral-400"
>
Empty note Empty note
</p> </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"> <div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span <span
@@ -342,88 +364,114 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</span> </span>
</div> </div>
<!-- Toolbar overlays the card's top-right on hover/focus as a floating pill <!-- The card's controls: the drag grip, then the action set.
(window-control style) instead of reserving a permanent row so at rest Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
the card is content-sized with even padding, not text pinned to the top them where the device can actually use them. Where a pointer hovers they lift
above an empty strip. out of flow into the floating top-corner pills they have always been; where
`hover-reveal`: on a device that can't hover this is the ONLY way to pin, nothing can hover they stay here, in flow, as a footer row. The alternative
colour or archive a note, so there it stays visible (see style.css). --> (a permanently visible overlay) sat on top of the note's own title, which is
<div what a phone was showing.
class="hover-reveal pointer-events-none absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10" `hover-reveal` still handles the visibility half see style.css. -->
> <div class="note-actions">
<template v-if="note.trashed"> <!-- Reorder is gated behind this grip so a normal click or tap never starts a
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)"> drag. Board views only (reorderable). A pointer-only affordance there is
<Icon name="restore" /> no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
</button> to us instead of the browser's scrolling. -->
<button
type="button"
class="icon-btn"
title="Delete forever"
aria-label="Delete forever"
@click="notes.deleteForever(note.id)"
>
<Icon name="trash" />
</button>
</template>
<template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button
type="button"
class="icon-btn"
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
:title="note.pinned ? 'Unpin' : 'Pin'"
:aria-label="note.pinned ? 'Unpin' : 'Pin'"
:aria-pressed="note.pinned"
@click="notes.setPinned(note.id, !note.pinned)"
>
<Icon name="pin" />
</button>
<button
type="button"
class="icon-btn"
:title="note.archived ? 'Unarchive' : 'Archive'"
:aria-label="note.archived ? 'Unarchive' : 'Archive'"
@click="notes.setArchived(note.id, !note.archived)"
>
<Icon name="archive" />
</button>
<button
type="button"
class="icon-btn"
title="Move to trash"
aria-label="Move to trash"
@click="notes.trash(note.id)"
>
<Icon name="trash" />
</button>
</template>
</div>
<div
v-if="colorOpen"
class="absolute right-1.5 top-11 z-20 flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button <button
v-for="key in NOTE_COLOR_KEYS" v-if="canDrag()"
:key="key"
type="button" type="button"
:title="NOTE_COLOR_LABELS[key]" tabindex="-1"
:aria-label="NOTE_COLOR_LABELS[key]" class="note-grip hover-reveal pointer-events-none flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand" title="Drag to reorder"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']" aria-label="Drag to reorder"
@click.stop="pickColor(key)" @pointerdown="onGripDown"
/> @pointermove="onGripMove"
@pointerup="onGripUp"
@pointercancel="onGripCancel"
>
<Icon name="grip" />
</button>
<div
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
>
<template v-if="note.trashed">
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)">
<Icon name="restore" />
</button>
<button
type="button"
class="icon-btn"
title="Delete forever"
aria-label="Delete forever"
@click="notes.deleteForever(note.id)"
>
<Icon name="trash" />
</button>
</template>
<template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button
type="button"
class="icon-btn"
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
:title="note.pinned ? 'Unpin' : 'Pin'"
:aria-label="note.pinned ? 'Unpin' : 'Pin'"
:aria-pressed="note.pinned"
@click="notes.setPinned(note.id, !note.pinned)"
>
<Icon name="pin" />
</button>
<button
type="button"
class="icon-btn"
:title="note.archived ? 'Unarchive' : 'Archive'"
:aria-label="note.archived ? 'Unarchive' : 'Archive'"
@click="notes.setArchived(note.id, !note.archived)"
>
<Icon name="archive" />
</button>
<button
type="button"
class="icon-btn"
title="Move to trash"
aria-label="Move to trash"
@click="notes.trash(note.id)"
>
<Icon name="trash" />
</button>
</template>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
@click.stop="pickColor(key)"
/>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
+63 -332
View File
@@ -1,9 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue"; import { computed, nextTick, onMounted, ref, watch } from "vue";
import { repo } from "../adapters";
import { useNotesStore } from "../stores/notes"; import { useNotesStore } from "../stores/notes";
import { useConfigStore } from "../stores/config";
import { useTitlesStore, type TitleEntry } from "../stores/titles";
import ColorPicker from "./ColorPicker.vue"; import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue"; import LabelPicker from "./LabelPicker.vue";
@@ -26,43 +23,39 @@ const props = withDefaults(defineProps<{ note?: Note | null; initialBody?: strin
}); });
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>(); const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore(); const notes = useNotesStore();
const config = useConfigStore();
const titles = useTitlesStore();
const noteId = ref<string | null>(props.note?.id ?? null); const noteId = ref<string | null>(props.note?.id ?? null);
const title = ref(props.note?.title ?? "");
const body = ref(props.note?.body ?? props.initialBody); const body = ref(props.note?.body ?? props.initialBody);
const color = ref<NoteColor>(props.note?.color ?? "default"); const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []); const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
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 saving = ref(false);
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null); const bodyInput = ref<HTMLTextAreaElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref(""); const uploadError = ref("");
const backlinks = ref<{ id: string; title: string }[]>([]);
// Baseline for edit-mode change detection (save only when text actually changed). // Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({ const baseline = ref<{ body: string; color: NoteColor }>({
title: props.note?.title ?? null,
body: props.note?.body ?? "", body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor, color: (props.note?.color ?? "default") as NoteColor,
}); });
const isCreate = computed(() => noteId.value === null); 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. // Rich features need a saved note; in compose they light up once there's content.
const richEnabled = computed(() => !isCreate.value || hasContent.value); const richEnabled = computed(() => !isCreate.value || hasContent.value);
// A synthetic note for compose mode (before anything is persisted), so the shared // 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>(() => ({ const draftNote = computed<Note>(() => ({
id: "", id: "",
title: title.value.trim() || null,
display_title: "", display_title: "",
body: body.value, body: body.value,
color: color.value, color: color.value,
kind: createKind.value,
position: 0, position: 0,
pinned: false, pinned: false,
archived: false, archived: false,
@@ -82,42 +75,34 @@ const liveNote = computed<Note>(() =>
? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value) ? (notes.items.find((n) => n.id === noteId.value) ?? props.note ?? draftNote.value)
: draftNote.value, : draftNote.value,
); );
// Only edit-mode list notes render the interactive checklist; compose-list types // The checklist renders once the note has items, or once someone has asked for one.
// lines into the textarea (they become items on create). // It sits BELOW the body rather than instead of it — a note can carry both, which is
const showChecklist = computed(() => !isCreate.value && liveNote.value.kind === "list"); // the whole point of the merge.
const isListMode = computed(() => (isCreate.value ? createKind.value === "list" : liveNote.value.kind === "list")); //
const bodyPlaceholder = computed(() => // Items need a persisted note to hang off, so this is a rich action like attaching a
isCreate.value && createKind.value === "list" ? "One item per line…" : "Take a note… ([[ to link a note)", // 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). // Keep local state in sync when the edited note changes (modal reused for another note).
watch( watch(
() => props.note, () => props.note,
(n) => { (n) => {
noteId.value = n?.id ?? null; noteId.value = n?.id ?? null;
title.value = n?.title ?? "";
body.value = n?.body ?? ""; body.value = n?.body ?? "";
color.value = (n?.color ?? "default") as NoteColor; color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : []; labelList.value = n ? [...n.labels] : [];
baseline.value = { 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 ---- // ---- persistence ----
async function createFromFields(): Promise<void> { async function createFromFields(): Promise<void> {
let created: Note; const created = await notes.create({ body: body.value, color: color.value });
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 });
}
noteId.value = created.id; 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 // Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
@@ -143,13 +128,13 @@ async function flush(): Promise<void> {
return; return;
} }
const b = baseline.value; const b = baseline.value;
const nextBody = showChecklist.value ? b.body : body.value; const nextBody = body.value;
const changed = (title.value.trim() || null) !== b.title || nextBody !== b.body || color.value !== b.color; const changed = nextBody !== b.body || color.value !== b.color;
if (!changed) return; if (!changed) return;
saving.value = true; saving.value = true;
try { try {
await notes.saveEdit(noteId.value as string, { title: title.value, body: nextBody, color: color.value }); await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
baseline.value = { title: title.value.trim() || null, body: nextBody, color: color.value }; baseline.value = { body: nextBody, color: color.value };
} finally { } finally {
saving.value = false; saving.value = false;
} }
@@ -157,13 +142,11 @@ async function flush(): Promise<void> {
function resetCompose(): void { function resetCompose(): void {
noteId.value = null; noteId.value = null;
title.value = "";
body.value = ""; body.value = "";
color.value = "default"; color.value = "default";
labelList.value = []; labelList.value = [];
createKind.value = "text"; checklistOpen.value = false;
baseline.value = { title: null, body: "", color: "default" }; baseline.value = { body: "", color: "default" };
linkMenu.value = false;
uploadError.value = ""; uploadError.value = "";
} }
@@ -242,22 +225,7 @@ function onBackdropMousedown(): void {
void dismiss(); void dismiss();
} }
async function loadBacklinks(): Promise<void> {
if (!noteId.value) {
backlinks.value = [];
return;
}
try {
backlinks.value = await repo.notes.backlinks(noteId.value);
} catch {
backlinks.value = [];
}
}
watch(() => noteId.value, loadBacklinks);
onMounted(async () => { onMounted(async () => {
void titles.load();
void loadBacklinks();
await nextTick(); await nextTick();
const el = bodyInput.value; const el = bodyInput.value;
el?.focus(); el?.focus();
@@ -265,92 +233,6 @@ onMounted(async () => {
if (el) el.selectionStart = el.selectionEnd = el.value.length; if (el) el.selectionStart = el.selectionEnd = el.value.length;
}); });
// ---- outgoing links (edit mode) ----
const outgoingLinks = computed(() => {
const re = /\[\[([^[\]]+)\]\]/g;
const seen = new Set<string>();
const out: { title: string; id: string | null }[] = [];
let match: RegExpExecArray | null;
while ((match = re.exec(body.value)) !== null) {
const t = match[1].trim();
const key = t.toLowerCase();
if (t && !seen.has(key)) {
seen.add(key);
out.push({ title: t, id: titles.resolve(t)?.id ?? null });
}
}
return out;
});
async function openLink(link: { title: string; id: string | null }) {
if (link.id) {
emit("navigate", link.id);
return;
}
const created = await notes.createTitled(link.title);
await titles.reload();
emit("navigate", created.id);
}
// ---- [[ link autocomplete in the body textarea ----
const linkMenu = ref(false);
const linkQuery = ref("");
const linkStart = ref(-1);
const linkSelected = ref(0);
const linkMatches = ref<TitleEntry[]>([]);
let linkTimer: ReturnType<typeof setTimeout> | undefined;
function refreshLinkMatches() {
if (linkTimer) clearTimeout(linkTimer);
const q = linkQuery.value.trim();
linkTimer = setTimeout(async () => {
try {
const results = await repo.notes.linkSearch(q);
linkMatches.value = results.filter((r) => r.id !== noteId.value).slice(0, 8);
} catch {
linkMatches.value = [];
}
linkSelected.value = 0;
}, 120);
}
function onBodyInput() {
const el = bodyInput.value;
if (!el) return;
const caret = el.selectionStart ?? 0;
const text = body.value.slice(0, caret);
const open = text.lastIndexOf("[[");
if (open === -1) {
linkMenu.value = false;
return;
}
const between = text.slice(open + 2);
if (between.includes("]") || between.includes("\n")) {
linkMenu.value = false;
return;
}
linkQuery.value = between;
linkStart.value = open;
linkSelected.value = 0;
linkMenu.value = true;
refreshLinkMatches();
}
function insertLink(t: string) {
const el = bodyInput.value;
const caret = el?.selectionStart ?? body.value.length;
const before = body.value.slice(0, linkStart.value);
const after = body.value.slice(caret);
const insertion = `[[${t}]]`;
body.value = before + insertion + after;
linkMenu.value = false;
const pos = before.length + insertion.length;
void nextTick(() => {
el?.focus();
el?.setSelectionRange(pos, pos);
});
}
function onBodyKeydown(e: KeyboardEvent) { function onBodyKeydown(e: KeyboardEvent) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). // Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
if (isCreate.value && e.key === "Enter" && e.shiftKey) { if (isCreate.value && e.key === "Enter" && e.shiftKey) {
@@ -358,31 +240,6 @@ function onBodyKeydown(e: KeyboardEvent) {
void commitAndContinue(); void commitAndContinue();
return; return;
} }
if (!linkMenu.value || linkMatches.value.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
linkSelected.value = Math.min(linkSelected.value + 1, linkMatches.value.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
linkSelected.value = Math.max(linkSelected.value - 1, 0);
} else if (e.key === "Enter" || e.key === "Tab") {
const m = linkMatches.value[linkSelected.value];
if (m) {
e.preventDefault();
insertLink(m.title);
}
} else if (e.key === "Escape") {
// Close only the menu — don't let Esc bubble to the frame's close/commit.
e.preventDefault();
e.stopPropagation();
linkMenu.value = false;
}
}
function onTitleEnter(e: KeyboardEvent) {
e.preventDefault();
if (e.shiftKey && isCreate.value) void commitAndContinue();
else bodyInput.value?.focus();
} }
// ---- reminder ---- // ---- reminder ----
@@ -426,29 +283,16 @@ function labelChip(c: string): string {
return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default; return LABEL_CHIP_CLASSES[c as NoteColor] ?? LABEL_CHIP_CLASSES.default;
} }
// ---- kind toggle: compose = local flag, edit = convert the existing note ---- // ---- add a checklist ----
async function toggleKind() { //
if (isCreate.value) { // Not a conversion any more. Nothing is moved, nothing is swapped: the note keeps its
createKind.value = createKind.value === "list" ? "text" : "list"; // body and gains a place to put items. Persists the draft first for the same reason
bodyInput.value?.focus(); // attaching a file does — an item needs a note to belong to.
return; async function addChecklist() {
} if (checklistOpen.value) return;
const id = noteId.value as string; const id = await ensureDraft();
if (liveNote.value.kind === "list") { if (!id) return;
await notes.setKind(id, "text"); checklistOpen.value = true;
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");
} }
// ---- attachments ---- // ---- attachments ----
@@ -477,41 +321,12 @@ async function uploadFile(file: File) {
} }
} }
// ---- link previews (URL unfurl) ---- // ---- link previews ----
const unfurling = ref<string | null>(null); // the URL currently being fetched //
const unfurlError = ref(""); // Nothing to trigger any more: the server unfurls a note's URLs in the background
// Bare http(s) URLs in the body; trailing sentence punctuation trimmed. // after each save (`unfurl_queue.py`) and the preview arrives on a later read. What
const URL_RE = /(https?:\/\/[^\s<>"'\])]+)/g; // is left here is removing one you don't want — the editor is the only place with
const detectedUrls = computed(() => { // room to offer that, and the card deliberately doesn't.
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;
}
}
async function onFileChange(e: Event) { async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
@@ -556,10 +371,9 @@ async function restoreRevisionAt(revId: string) {
const id = noteId.value; const id = noteId.value;
if (!id) return; if (!id) return;
const updated = await notes.restoreRevision(id, revId); const updated = await notes.restoreRevision(id, revId);
title.value = updated.title ?? "";
body.value = updated.body; body.value = updated.body;
color.value = updated.color; 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 void loadRevisions(); // the pre-restore state became a new revision
} }
function revLabel(iso: string | null): string { function revLabel(iso: string | null): string {
@@ -567,9 +381,7 @@ function revLabel(iso: string | null): string {
return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
} }
function revPreview(rev: NoteRevision): string { function revPreview(rev: NoteRevision): string {
const t = (rev.title ?? "").trim(); const s = rev.body.trim().replace(/\s+/g, " ");
const b = rev.body.trim().replace(/\s+/g, " ");
const s = t && b ? `${t}${b}` : t || b;
if (!s) return "(empty)"; if (!s) return "(empty)";
return s.length > 80 ? `${s.slice(0, 80)}` : s; return s.length > 80 ? `${s.slice(0, 80)}` : s;
} }
@@ -651,7 +463,7 @@ function revPreview(rev: NoteRevision): string {
</div> </div>
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p> <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"> <div v-if="liveNote.previews.length" class="flex flex-col gap-2">
<LinkPreview <LinkPreview
v-for="p in liveNote.previews" v-for="p in liveNote.previews"
@@ -661,64 +473,23 @@ function revPreview(rev: NoteRevision): string {
@remove="notes.deletePreview(liveNote.id, p.id)" @remove="notes.deletePreview(liveNote.id, p.id)"
/> />
</div> </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 <textarea
v-model="title" ref="bodyInput"
type="text" v-model="body"
placeholder="Title (optional)" rows="8"
class="w-full bg-transparent text-base font-semibold outline-none placeholder:text-neutral-400" :placeholder="bodyPlaceholder"
@keydown.enter="onTitleEnter" class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@keydown="onBodyKeydown"
/>
<!-- Below the body, not instead of it. -->
<NoteChecklist
v-if="showChecklist"
class="py-1"
:note-id="liveNote.id"
:items="liveNote.items"
editable
/> />
<div v-if="!showChecklist" class="relative">
<textarea
ref="bodyInput"
v-model="body"
rows="8"
:placeholder="bodyPlaceholder"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onBodyInput"
@keydown="onBodyKeydown"
/>
<ul
v-if="linkMenu && linkMatches.length"
class="absolute left-0 top-full z-10 mt-1 max-h-48 w-64 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<li v-for="(m, i) in linkMatches" :key="m.id">
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm"
:class="
i === linkSelected
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'
"
@mousemove="linkSelected = i"
@mousedown.prevent="insertLink(m.title)"
>
<span class="truncate">{{ m.title }}</span>
</button>
</li>
</ul>
</div>
<NoteChecklist v-else 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"> <div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
<span <span
@@ -786,44 +557,6 @@ function revPreview(rev: NoteRevision): string {
</button> </button>
</div> </div>
<div
v-if="!isCreate && (outgoingLinks.length || backlinks.length)"
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
>
<div v-if="outgoingLinks.length">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Links</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="link in outgoingLinks"
:key="link.title"
type="button"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
link.id
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'bg-black/5 text-neutral-500 dark:bg-white/10 dark:text-neutral-400'
"
:title="link.id ? `Open ${link.title}` : `Create ${link.title}`"
@click="openLink(link)"
>
{{ link.title }}<span v-if="!link.id" class="opacity-60"></span>
</button>
</div>
</div>
<div v-if="backlinks.length">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked from</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="b in backlinks"
:key="b.id"
type="button"
class="inline-flex items-center rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 hover:bg-black/10 dark:bg-white/10 dark:text-neutral-300"
@click="emit('navigate', b.id)"
>
{{ b.title }}
</button>
</div>
</div>
</div> </div>
<div <div
@@ -865,13 +598,12 @@ function revPreview(rev: NoteRevision): string {
</button> </button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" /> <input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button <button
v-if="!liveNote.trashed" v-if="richEnabled && !liveNote.trashed && !showChecklist"
type="button" type="button"
class="icon-btn" class="icon-btn"
:class="isListMode ? 'text-brand-700 dark:text-brand' : ''" title="Add a checklist"
:title="isListMode ? 'Switch to a note' : 'Make a checklist'" aria-label="Add a checklist"
:aria-pressed="isListMode" @click="addChecklist"
@click="toggleKind"
> >
<Icon name="checkbox" /> <Icon name="checkbox" />
</button> </button>
@@ -947,6 +679,5 @@ function revPreview(rev: NoteRevision): string {
</div> </div>
</div> </div>
</div> </div>
</div>
</Transition> </Transition>
</template> </template>
+2 -2
View File
@@ -8,8 +8,8 @@ import { captureMorphOrigin } from "./useEditorMorph";
// //
// - `onClose` lets a host clean up its own state (e.g. a board's compose flag). // - `onClose` lets a host clean up its own state (e.g. a board's compose flag).
// - `list` is the host's local note array, tried first when resolving a navigated // - `list` is the host's local note array, tried first when resolving a navigated
// [[wiki-link]] target before falling back to the store, then a fetch — so views // note id before falling back to the store, then a fetch — so views
// that keep their own list (reminders, timeline, search, graph) still resolve // that keep their own list (reminders, timeline, search) still resolve
// locally without duplicating the lookup. // locally without duplicating the lookup.
export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) { export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) {
const notes = useNotesStore(); const notes = useNotesStore();
-4
View File
@@ -17,8 +17,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
if (text) f.q = text; if (text) f.q = text;
const color = one(q.color); const color = one(q.color);
if (color) f.color = 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 (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true; if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true; if (one(q.has_attachment) === "true") f.has_attachment = true;
@@ -33,7 +31,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {}; const q: LocationQueryRaw = {};
if (f.q) q.q = f.q; if (f.q) q.q = f.q;
if (f.color) q.color = f.color; if (f.color) q.color = f.color;
if (f.kind) q.kind = f.kind;
if (f.label?.length) q.label = f.label; if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true"; if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true"; if (f.has_attachment) q.has_attachment = "true";
@@ -47,7 +44,6 @@ export function facetCount(f: NoteFacets): number {
let n = 0; let n = 0;
if (f.q) n++; if (f.q) n++;
if (f.color) n++; if (f.color) n++;
if (f.kind) n++;
n += f.label?.length ?? 0; n += f.label?.length ?? 0;
if (f.has_reminder) n++; if (f.has_reminder) n++;
if (f.has_attachment) n++; if (f.has_attachment) n++;
+13 -10
View File
@@ -2,12 +2,12 @@
// stays plain text; this only formats what's shown. We render the parsed tree as // stays plain text; this only formats what's shown. We render the parsed tree as
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a // Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced // small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
// code, and inline **bold** / *italic* / _italic_ / `code` — plus ThoughtSync's own // code, and inline **bold** / *italic* / _italic_ / `code`. Note: headings need a
// [[wiki-links]]. Note: headings need a space after `#`, so a #tag (no space) is // space after `#`, so a #tag (no space) is left as plain text and never mistaken for
// left as plain text and never mistaken for a heading. // a heading.
export interface InlineToken { export interface InlineToken {
type: "text" | "bold" | "italic" | "code" | "link"; type: "text" | "bold" | "italic" | "code";
value: string; value: string;
} }
@@ -19,9 +19,13 @@ export interface Block {
value?: string; value?: string;
} }
// Order matters: links + code are matched before emphasis so their contents aren't // Order matters: code is matched before emphasis so its contents aren't re-parsed;
// re-parsed; bold (**) before italic (*). Emphasis does not nest (v1). // bold (**) before italic (*). Emphasis does not nest (v1).
const INLINE_RE = /(\[\[[^[\]]+\]\])|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g; //
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
// renders as the literal characters someone typed, which is what it always was.
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
export function parseInline(text: string): InlineToken[] { export function parseInline(text: string): InlineToken[] {
const tokens: InlineToken[] = []; const tokens: InlineToken[] = [];
@@ -31,9 +35,8 @@ export function parseInline(text: string): InlineToken[] {
while ((m = INLINE_RE.exec(text)) !== null) { while ((m = INLINE_RE.exec(text)) !== null) {
if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) }); if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) });
const raw = m[0]; const raw = m[0];
if (m[1]) tokens.push({ type: "link", value: raw.slice(2, -2).trim() }); if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[2]) tokens.push({ type: "code", value: raw.slice(1, -1) }); else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
else if (m[3]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
else tokens.push({ type: "italic", value: raw.slice(1, -1) }); else tokens.push({ type: "italic", value: raw.slice(1, -1) });
last = m.index + raw.length; last = m.index + raw.length;
} }
-2
View File
@@ -20,8 +20,6 @@ const router = createRouter({
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") }, { path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
{ path: "trash", name: "trash", 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: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
{ path: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") }, { path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") }, { path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
], ],
+5 -22
View File
@@ -5,14 +5,11 @@ import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors"; import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash"; 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 // 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. // view's stored params). All optional; empty = the plain, unfiltered board.
export interface NoteFacets { export interface NoteFacets {
q?: string; q?: string;
color?: string; color?: string;
kind?: NoteKind;
label?: string[]; label?: string[];
has_reminder?: boolean; has_reminder?: boolean;
has_attachment?: boolean; has_attachment?: boolean;
@@ -55,23 +52,20 @@ export interface LinkPreview {
site_name: string | null; 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 { export interface NoteRevision {
id: string; id: string;
title: string | null;
body: string; body: string;
created_at: string | null; created_at: string | null;
} }
export interface Note { export interface Note {
id: string; id: string;
title: string | null; // The note's NAME: its first body line, else its first checklist item
// The note's display NAME: explicit title, else its first body line (server-derived). // (server-derived). Every note has one, so every note has something to be called.
// Every note has one, so body-only notes are still nameable + [[link]]-able.
display_title: string; display_title: string;
body: string; body: string;
color: NoteColor; color: NoteColor;
kind: NoteKind;
position: number; position: number;
pinned: boolean; pinned: boolean;
archived: boolean; archived: boolean;
@@ -138,10 +132,8 @@ export const useNotesStore = defineStore("notes", () => {
} }
async function create(input: { async function create(input: {
title: string;
body: string; body: string;
color: NoteColor; color: NoteColor;
kind?: NoteKind;
items?: string[]; items?: string[];
}): Promise<Note> { }): Promise<Note> {
const note = await repo.notes.create(input); const note = await repo.notes.create(input);
@@ -152,7 +144,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate( async function mutate(
id: string, id: string,
changes: Partial< changes: Partial<
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence"> Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>, >,
): Promise<void> { ): Promise<void> {
reconcile(await repo.notes.update(id, changes)); 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) }); useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
}; };
const setColor = (id: string, color: NoteColor) => mutate(id, { color }); 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 setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence }); 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> { async function completeReminder(id: string): Promise<void> {
reconcile(await repo.notes.completeReminder(id)); reconcile(await repo.notes.completeReminder(id));
@@ -224,12 +215,6 @@ export const useNotesStore = defineStore("notes", () => {
} }
} }
async function createTitled(title: string): Promise<Note> {
const created = await repo.notes.createTitled(title);
reconcile(created);
return created;
}
async function reorder(orderedIds: string[]): Promise<void> { async function reorder(orderedIds: string[]): Promise<void> {
// Optimistically assign positions matching the backend (total - index), sort, // Optimistically assign positions matching the backend (total - index), sort,
// then persist. // then persist.
@@ -290,7 +275,6 @@ export const useNotesStore = defineStore("notes", () => {
setPinned, setPinned,
setArchived, setArchived,
setColor, setColor,
setKind,
setReminder, setReminder,
setRecurrence, setRecurrence,
completeReminder, completeReminder,
@@ -306,7 +290,6 @@ export const useNotesStore = defineStore("notes", () => {
deletePreview, deletePreview,
importNotes, importNotes,
fetchOne, fetchOne,
createTitled,
reorder, reorder,
trash, trash,
restore, restore,
+7 -7
View File
@@ -7,7 +7,12 @@ export interface TitleEntry {
title: string; title: string;
} }
// Owner's {id,title} index, used to resolve [[wiki-links]] client-side. // Owner's {id, name} index of every non-trashed note.
//
// Outlived [[wiki-links]] (note 2897), which is what it was originally built for,
// because the command palette lists it so someone can jump straight to a note by
// name. That is recall, which is what this app is for; `resolve()` went with the
// links.
export const useTitlesStore = defineStore("titles", () => { export const useTitlesStore = defineStore("titles", () => {
const items = ref<TitleEntry[]>([]); const items = ref<TitleEntry[]>([]);
const loaded = ref(false); const loaded = ref(false);
@@ -23,10 +28,5 @@ export const useTitlesStore = defineStore("titles", () => {
await load(); await load();
} }
function resolve(title: string): TitleEntry | null { return { items, loaded, load, reload };
const norm = title.trim().toLowerCase();
return items.value.find((t) => t.title.trim().toLowerCase() === norm) ?? null;
}
return { items, loaded, load, reload, resolve };
}); });
+101
View File
@@ -8,6 +8,35 @@ body,
height: 100%; height: 100%;
} }
/* Track the VISUAL viewport, not the layout viewport.
*
* On a phone the two diverge the moment the on-screen keyboard opens or the URL bar
* retracts: `100%` keeps the taller layout viewport, which slides the sticky header
* off screen and can park a focused field underneath the keyboard. `dvh` is the unit
* that follows the box actually being shown.
*
* Same three selectors rather than a fourth rule, so the box model is unchanged and
* only the number moves; behind @supports so a browser without `dvh` keeps the
* `100%` above instead of falling back to nothing. */
@supports (height: 100dvh) {
html,
body,
#app {
height: 100dvh;
}
}
/* Landscape on a notched phone puts the cutout down one SIDE of the page, and with
* `viewport-fit=cover` (index.html) the page is drawing under it. Applied to body so
* every view inherits it, rather than each container remembering; the insets are 0
* on every device that has no cutout, and 0 in portrait, so this costs nothing where
* it isn't needed. Top and bottom are NOT done here — a sticky header and a scrolling
* board need them in their own boxes, not on the page. */
body {
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
body { body {
@apply bg-neutral-50 text-neutral-900 antialiased; @apply bg-neutral-50 text-neutral-900 antialiased;
} }
@@ -61,6 +90,78 @@ body {
} }
} }
/* A note card's own controls — the drag grip and the pin/colour/archive/trash set.
* ONE element each, in TWO placements, because "always visible" and "floating over
* the card's top corners" cannot both be true without the controls sitting on top of
* the note's own words.
*
* That is exactly what a phone got. `.hover-reveal` above made the pills permanent
* where nothing can hover (task 2697, correctly — they are the only way to pin or
* archive), but they were still absolutely positioned, so they covered the title:
* "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked on
* the first three characters.
*
* So the default — no hover — is a footer row IN FLOW, which cannot overlap anything
* by construction. A pointer that can hover lifts them back out into the floating
* pills they have always been, where they cost no vertical space and appear only on
* approach. `display: contents` drops the flex row itself out of the way so each
* child positions against the card.
*
* Keyed on hover rather than width, for the reason `.hover-reveal` already gives: a
* narrow window on a laptop still hovers, and a wide tablet still doesn't. */
.note-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.125rem;
margin-top: 0.5rem;
}
.note-grip {
/* Pushes the action set to the far edge, so the two read as opposite ends of a
* footer rather than as a clump. */
margin-right: auto;
}
.note-actions-set {
position: relative; /* the colour popover anchors here in both placements */
}
@media (hover: hover) {
.note-actions {
display: contents;
}
.note-grip {
position: absolute;
left: 0.375rem;
top: 0.375rem;
z-index: 10;
margin-right: 0;
}
.note-actions-set {
position: absolute;
right: 0.375rem;
top: 0.375rem;
}
}
/* The per-card colour popover, anchored to whichever end of the card the action set
* currently occupies: it opens DOWNWARD from a floating top-corner pill, and UPWARD
* from a footer row, so in both cases it grows into the card rather than off it. */
.note-swatches {
position: absolute;
right: 0;
bottom: 100%;
margin-bottom: 0.375rem;
z-index: 20;
}
@media (hover: hover) {
.note-swatches {
top: 100%;
bottom: auto;
margin-top: 0.375rem;
margin-bottom: 0;
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's /* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other * markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups. * and non-board grids are three TransitionGroups.
+15 -4
View File
@@ -258,16 +258,27 @@ async function onDrop(targetId: string) {
</script> </script>
<template> <template>
<div class="mx-auto w-full max-w-6xl px-4 py-6"> <!-- `pb-[…safe-area-inset-bottom]`: the board is the last thing on the page, so
it is what ends up underneath the gesture bar once `viewport-fit=cover` lets
the page draw there. Added to the existing py-6 rather than replacing it. -->
<div class="mx-auto w-full max-w-6xl px-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))] pt-6">
<button <button
v-if="isMainBoard" v-if="isMainBoard"
type="button" type="button"
class="mx-auto mb-4 block w-full max-w-xl rounded-xl border border-dashed border-neutral-300 px-4 py-2.5 text-center text-sm text-neutral-400 transition hover:border-neutral-400 hover:text-neutral-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:border-neutral-500" class="mx-auto mb-4 block w-full max-w-xl rounded-xl border border-dashed border-neutral-300 px-4 py-2.5 text-center text-sm text-neutral-400 transition hover:border-neutral-400 hover:text-neutral-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:border-neutral-500"
@click="ui.requestCompose()" @click="ui.requestCompose()"
> >
Press <!-- Two ways of saying the same thing, because the keyboard half is a lie on a
<kbd class="mx-0.5 rounded border border-neutral-300 px-1 text-xs dark:border-neutral-600">Enter</kbd> phone: there is no Enter key until something is already focused, and the
or start typing to add a note box has always been tappable. `hidden sm:inline` / `sm:hidden` rather than
a JS pointer check this is presentation, and it should be right in the
first paint rather than after one. -->
<span class="hidden sm:inline">
Press
<kbd class="mx-0.5 rounded border border-neutral-300 px-1 text-xs dark:border-neutral-600">Enter</kbd>
or start typing to add a note
</span>
<span class="sm:hidden">Tap to add a note</span>
</button> </button>
<FilterBar v-if="isMainBoard" /> <FilterBar v-if="isMainBoard" />
-420
View File
@@ -1,420 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api/client";
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
import { useNoteEditor } from "../composables/useNoteEditor";
import AsyncState from "../components/AsyncState.vue";
import NoteEditor from "../components/NoteEditor.vue";
type NodeKind = "note" | "label";
interface GNode {
id: string;
title: string;
color: string;
kind: NodeKind;
labelId?: string;
x: number;
y: number;
vx: number;
vy: number;
}
interface GEdge {
source: string;
target: string;
kind?: string;
}
const WIDTH = 1000;
const HEIGHT = 700;
const router = useRouter();
const allNodes = ref<GNode[]>([]);
const edges = ref<GEdge[]>([]);
const loading = ref(true);
const error = ref("");
// Editor host: resolve a node id against the store, else fetch (shared controller).
const { editing, close: closeEditor, navigate } = useNoteEditor();
// Unlinked notes float in the space by default — the graph is a gentle overview,
// not a links-only surface. Label hubs are on by default so tags cluster notes.
const showAll = ref(true);
const showLabels = ref(true);
const svgRef = ref<SVGSVGElement | null>(null);
const gRef = ref<SVGGElement | null>(null);
// Pan/zoom applied to the inner <g>.
const scale = ref(1);
const tx = ref(0);
const ty = ref(0);
let frame = 0;
let raf = 0;
// Label membership edges drop out when the label hubs are hidden.
const visibleEdges = computed(() =>
showLabels.value ? edges.value : edges.value.filter((e) => e.kind !== "label"),
);
const connectedIds = computed(() => {
const s = new Set<string>();
for (const e of visibleEdges.value) {
s.add(e.source);
s.add(e.target);
}
return s;
});
// Hide label hubs when toggled off; otherwise show everything (unlinked notes
// float too) unless "show unlinked" is off, in which case keep only connected nodes.
const activeNodes = computed(() =>
allNodes.value.filter((n) => {
if (n.kind === "label" && !showLabels.value) return false;
if (showAll.value) return true;
return connectedIds.value.has(n.id);
}),
);
const activeIds = computed(() => new Set(activeNodes.value.map((n) => n.id)));
const edgeLines = computed(() => {
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
const out: { x1: number; y1: number; x2: number; y2: number; label: boolean }[] = [];
for (const e of visibleEdges.value) {
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
const s = byId.get(e.source);
const t = byId.get(e.target);
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y, label: e.kind === "label" });
}
return out;
});
function fill(color: string): string {
return NOTE_NODE_FILL[color as NoteColor] ?? NOTE_NODE_FILL.default;
}
async function loadGraph() {
loading.value = true;
error.value = "";
try {
const res = await api.get<{
nodes: { id: string; title: string; color: string; kind: NodeKind; label_id?: string }[];
edges: GEdge[];
}>("/api/graph");
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
const count = Math.max(res.nodes.length, 1);
allNodes.value = res.nodes.map((n, i) => {
const angle = (i / count) * Math.PI * 2;
return {
id: n.id,
title: n.title,
color: n.color,
kind: n.kind,
labelId: n.label_id,
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
vx: 0,
vy: 0,
};
});
edges.value = res.edges;
} catch (e) {
error.value = (e as { error?: string }).error ?? "Couldn't load the graph.";
allNodes.value = [];
edges.value = [];
} finally {
loading.value = false;
}
reheat();
}
function reheat() {
frame = 0;
cancelAnimationFrame(raf);
raf = 0;
if (activeNodes.value.length) simulate();
}
function simulate() {
const list = activeNodes.value;
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
const byId = new Map(list.map((n) => [n.id, n]));
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
const a = list[i];
const b = list[j];
let dx = a.x - b.x;
let dy = a.y - b.y;
let d2 = dx * dx + dy * dy;
if (d2 < 0.01) {
d2 = 0.01;
dx = Math.random();
dy = Math.random();
}
const d = Math.sqrt(d2);
const force = 6000 / d2;
const fx = (dx / d) * force;
const fy = (dy / d) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
}
for (const e of visibleEdges.value) {
const s = byId.get(e.source);
const t = byId.get(e.target);
if (!s || !t) continue;
const dx = t.x - s.x;
const dy = t.y - s.y;
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
// Label-membership springs sit a touch longer so hubs ring their notes.
const rest = e.kind === "label" ? 150 : 130;
const diff = (d - rest) * 0.02;
const fx = (dx / d) * diff;
const fy = (dy / d) * diff;
s.vx += fx;
s.vy += fy;
t.vx -= fx;
t.vy -= fy;
}
for (const n of list) {
if (n === dragNode) {
n.vx = 0;
n.vy = 0;
continue; // pinned to the cursor while dragging
}
n.vx += (cx - n.x) * 0.002;
n.vy += (cy - n.y) * 0.002;
n.vx *= 0.85;
n.vy *= 0.85;
n.x += n.vx;
n.y += n.vy;
}
frame++;
// Keep running while cooling, or indefinitely while a node is being dragged.
raf = frame < 400 || dragNode ? requestAnimationFrame(simulate) : 0;
}
// --- pointer interaction: drag a node, pan the background, wheel-zoom ---
let dragNode: GNode | null = null;
let dragMoved = false;
let downPos = { x: 0, y: 0 };
let panning = false;
let panLast = { x: 0, y: 0 };
function toLocal(el: SVGGraphicsElement | null, e: MouseEvent) {
const ctm = el?.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
return { x: p.x, y: p.y };
}
function onNodeDown(n: GNode, e: MouseEvent) {
e.stopPropagation();
dragNode = n;
dragMoved = false;
downPos = { x: e.clientX, y: e.clientY };
reheat();
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
function onBgDown(e: MouseEvent) {
panning = true;
panLast = toLocal(svgRef.value, e);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
function onMove(e: MouseEvent) {
if (dragNode) {
if (Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 3) dragMoved = true;
const p = toLocal(gRef.value, e);
dragNode.x = p.x;
dragNode.y = p.y;
} else if (panning) {
const p = toLocal(svgRef.value, e);
tx.value += p.x - panLast.x;
ty.value += p.y - panLast.y;
panLast = p;
}
}
function onUp() {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
const node = dragNode;
const moved = dragMoved;
dragNode = null;
panning = false;
// A press without a drag is a click.
if (node && !moved) clickNode(node);
}
function clickNode(n: GNode) {
// Label hub → jump to that label's board lens (one space, many lenses).
if (n.kind === "label" && n.labelId) {
void router.push(`/label/${n.labelId}`);
return;
}
void navigate(n.id);
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const vb = toLocal(svgRef.value, e);
const gx = (vb.x - tx.value) / scale.value;
const gy = (vb.y - ty.value) / scale.value;
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
scale.value = Math.min(Math.max(scale.value * factor, 0.3), 3);
tx.value = vb.x - gx * scale.value;
ty.value = vb.y - gy * scale.value;
}
function resetView() {
scale.value = 1;
tx.value = 0;
ty.value = 0;
}
function toggleAll() {
showAll.value = !showAll.value;
reheat();
}
function toggleLabels() {
showLabels.value = !showLabels.value;
reheat();
}
onMounted(loadGraph);
onBeforeUnmount(() => {
cancelAnimationFrame(raf);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
});
</script>
<template>
<div class="flex h-full flex-col p-4">
<!-- Titled by the shell's persistent lens name (task 1913). -->
<div class="mb-3 flex flex-wrap items-center justify-end gap-3">
<div class="flex items-center gap-3 text-sm">
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
<input type="checkbox" class="accent-brand" :checked="showLabels" @change="toggleLabels" />
Show labels
</label>
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
Show unlinked notes
</label>
<button
type="button"
class="rounded-md border border-neutral-300 px-2 py-1 text-xs hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="resetView"
>
Reset view
</button>
</div>
</div>
<AsyncState
:loading="loading"
:error="error || undefined"
error-title="Couldn't load the graph"
@retry="loadGraph"
>
<div v-if="allNodes.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No notes yet</h2>
<p class="mt-1 text-sm text-neutral-400">
Create notes and link them with
<span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them here.
</p>
</div>
<div v-else-if="activeNodes.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Nothing connected yet</h2>
<p class="mt-1 text-sm text-neutral-400">
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span>, add
<span class="font-mono text-brand-700 dark:text-brand">#tags</span>, or
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
show all notes
</button>.
</p>
</div>
<div
v-else
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
>
<svg
ref="svgRef"
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
class="h-full w-full cursor-grab select-none touch-none"
preserveAspectRatio="xMidYMid meet"
@mousedown="onBgDown"
@wheel="onWheel"
>
<g ref="gRef" :transform="`translate(${tx} ${ty}) scale(${scale})`">
<line
v-for="(l, i) in edgeLines"
:key="`e${i}`"
:x1="l.x1"
:y1="l.y1"
:x2="l.x2"
:y2="l.y2"
class="stroke-neutral-300 dark:stroke-neutral-700"
:stroke-width="l.label ? 1 : 1.5"
:stroke-dasharray="l.label ? '3 3' : undefined"
/>
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
<!-- Label hubs read as a larger ringed node so tags stand out from notes. -->
<circle
v-if="n.kind === 'label'"
:cx="n.x"
:cy="n.y"
r="12"
:fill="fill(n.color)"
fill-opacity="0.9"
class="stroke-neutral-50 dark:stroke-neutral-950"
stroke-width="3"
/>
<circle
v-else
:cx="n.x"
:cy="n.y"
r="8"
:fill="fill(n.color)"
class="stroke-neutral-50 dark:stroke-neutral-950"
stroke-width="1.5"
/>
<text
:x="n.x"
:y="n.kind === 'label' ? n.y - 17 : n.y - 13"
text-anchor="middle"
:class="
n.kind === 'label'
? 'fill-neutral-700 text-[12px] font-semibold dark:fill-neutral-100'
: 'fill-neutral-600 text-[12px] dark:fill-neutral-300'
"
>
{{ n.title }}
</text>
</g>
</g>
</svg>
</div>
</AsyncState>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="navigate" />
</template>
</div>
</template>
-48
View File
@@ -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>
+10
View File
@@ -12,6 +12,10 @@ interface SettingItem {
label: string; label: string;
description: string; description: string;
group: 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(); const config = useConfigStore();
@@ -131,10 +135,16 @@ onMounted(load);
:checked="Boolean(it.value)" :checked="Boolean(it.value)"
@change="it.value = ($event.target as HTMLInputElement).checked" @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 <input
v-else-if="it.type === 'int'" v-else-if="it.type === 'int'"
:id="it.key" :id="it.key"
type="number" 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" 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)" :value="Number(it.value)"
@input="it.value = Number(($event.target as HTMLInputElement).value)" @input="it.value = Number(($event.target as HTMLInputElement).value)"
+7 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "thoughtsync" name = "thoughtsync"
version = "0.1.0" version = "0.2.0"
description = "Self-hosted personal thought-capture web app (FabledSword family)" description = "Self-hosted personal thought-capture web app (FabledSword family)"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@@ -29,6 +29,12 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] 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] [tool.ruff]
line-length = 120 line-length = 120
+1 -1
View File
@@ -1,3 +1,3 @@
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family).""" """ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
__version__ = "0.1.0" __version__ = "0.2.0"
+85 -8
View File
@@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import mimetypes import mimetypes
import os import os
import secrets import secrets
from contextlib import suppress from contextlib import suppress
from datetime import timedelta 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 quart.sessions import SecureCookieSessionInterface
from . import __version__ from . import __version__
@@ -15,15 +16,27 @@ from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config from .config import Config
from .db import session_scope from .db import session_scope
from .graph import bp as graph_bp
from .labels import bp as labels_bp from .labels import bp as labels_bp
from .notes import bp as notes_bp from .notes import bp as notes_bp
from .proxy import is_https
from .retention import run_sweeper from .retention import run_sweeper
from .saved_filters import bp as saved_filters_bp 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 .settings_api import bp as settings_bp
from .sync import bp as sync_bp, protocol_advertisement 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") 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 # `.webmanifest` isn't in every base image's mime map; register it so the PWA
@@ -42,10 +55,7 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface):
""" """
def get_cookie_secure(self, app: Quart) -> bool: def get_cookie_secure(self, app: Quart) -> bool:
if not has_request_context(): return is_https()
return False
forwarded = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip().lower()
return forwarded == "https" or request.is_secure
def create_app() -> Quart: def create_app() -> Quart:
@@ -68,7 +78,6 @@ def create_app() -> Quart:
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp) app.register_blueprint(notes_bp)
app.register_blueprint(labels_bp) app.register_blueprint(labels_bp)
app.register_blueprint(graph_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(sync_bp) app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp) app.register_blueprint(saved_filters_bp)
@@ -85,6 +94,11 @@ def create_app() -> Quart:
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days) app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
pass 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 # Expire old trash in the background (retention.py). One task per process is
# correct because the image serves with a single hypercorn worker (Dockerfile); # 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 # if that ever gains `--workers`, this needs a lock so N workers don't each
@@ -101,6 +115,69 @@ def create_app() -> Quart:
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await task await task
@app.after_request
async def _security_headers(response):
"""Headers a publicly-reachable instance should be sending.
None of these change how the app behaves for a legitimate caller; they narrow
what a browser will do if something else goes wrong.
The CSP is the substantive one. `script-src 'self'` means that even if some
future path did manage to reflect user text into the page, the browser would
refuse to run it — the app has no inline scripts and no third-party scripts,
so nothing legitimate is given up. The exceptions are honest ones:
- `style-src 'unsafe-inline'` — Vue writes inline styles itself (`v-show`
toggling display, TransitionGroup's FLIP setting transforms). Inline
STYLE is not an execution primitive the way inline script is.
- `img-src https: http:` — link previews render the remote og:image of
whatever was linked, which is an arbitrary host by definition. Both
schemes, because a LAN install is served over http and would otherwise
lose every preview image; on an https instance the browser blocks the
http ones as mixed content anyway, so naming it concedes nothing.
- `blob:`/`data:` — attachment previews and the desktop's blob URI scheme.
`frame-ancestors 'none'` replaces the older X-Frame-Options and is what stops
the app being framed for clickjacking; `form-action 'self'` stops a form from
being pointed at another origin.
"""
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"base-uri 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https: http:; "
"font-src 'self' data:; "
"media-src 'self' blob:; "
"connect-src 'self'",
)
# Content-type sniffing turns a file we said was text into whatever the bytes
# look like. The attachment route already sets this; every other response
# deserves it too.
response.headers.setdefault("X-Content-Type-Options", "nosniff")
# Note titles and label names end up in the URL of a search or a label lens,
# and a full Referer would hand them to any site a link preview points at.
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
# Nothing here uses a camera, a microphone or a location, so nothing embedded
# in a page should be able to ask on its behalf.
response.headers.setdefault(
"Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()"
)
# HSTS only where the request already arrived over TLS — the same detection
# the session cookie uses. Sending it on a plain-HTTP LAN install would tell
# the browser to refuse the only scheme that install serves.
#
# Scoped to this host: no `includeSubDomains` and no `preload`, both of which
# 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():
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000")
return response
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
return jsonify({"status": "ok", "version": app.config["APP_VERSION"]}) return jsonify({"status": "ok", "version": app.config["APP_VERSION"]})
+131 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import functools import functools
import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -11,11 +12,27 @@ from .common import iso
from .db import session_scope from .db import session_scope
from .models.device_token import DeviceToken from .models.device_token import DeviceToken
from .models.user import User from .models.user import User
from .security import generate_token, hash_password, hash_token, verify_password from .proxy import client_address
from .settings import get_setting from .ratelimit import (
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, set_settings
bp = Blueprint("auth", __name__, url_prefix="/api/auth") 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" SESSION_KEY = "user_id"
MIN_PASSWORD_LEN = 8 MIN_PASSWORD_LEN = 8
DEVICE_NAME_CAP = 100 DEVICE_NAME_CAP = 100
@@ -106,6 +123,53 @@ def require_admin(fn):
return wrapper return wrapper
def _throttled(retry_after: int):
"""The 429 every throttled credential route returns.
Deliberately says nothing about WHICH limit was hit or how many attempts are
left — that would tell someone probing whether the email they guessed exists.
`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,
{"Retry-After": str(retry_after)},
)
def _sign_in_block(email: str) -> int | None:
"""Seconds to wait before this sign-in may be attempted, or None to proceed.
Checked BEFORE the password is verified, so a throttled attempt costs no bcrypt
— which is the other half of what this protects: hashing is deliberately slow,
and an unauthenticated caller who can trigger it without limit has a CPU
exhaustion primitive, not just a guessing one.
"""
address = client_address()
waits = [
sign_in_by_address.retry_after(address),
sign_in_by_account.retry_after(email) if email else None,
]
live = [w for w in waits if w is not None]
return max(live) if live else None
def _sign_in_failed(email: str) -> None:
address = client_address()
sign_in_by_address.record(address)
if email:
sign_in_by_account.record(email)
def _sign_in_succeeded(email: str) -> None:
"""Clear the account's history on success. The address keeps its count: one
correct password does not vouch for the other attempts from there."""
if email:
sign_in_by_account.forget(email)
@bp.post("/register") @bp.post("/register")
async def register(): async def register():
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
@@ -120,12 +184,21 @@ async def register():
if not display_name: if not display_name:
display_name = email.split("@", 1)[0] display_name = email.split("@", 1)[0]
# Counted by attempt rather than by failure: a rejected registration still cost a
# round trip and a uniqueness check, and on an instance with signups open the
# thing worth bounding is how fast accounts can appear at all.
wait = register_by_address.retry_after(client_address())
if wait is not None:
return _throttled(wait)
register_by_address.record(client_address())
async with session_scope() as db: async with session_scope() as db:
user_count = await db.scalar(select(func.count()).select_from(User)) or 0 user_count = await db.scalar(select(func.count()).select_from(User)) or 0
is_first = user_count == 0 is_first = user_count == 0
# The first account bootstraps the admin and is always allowed, even when # The first account bootstraps the admin and is always allowed, even when
# registration is otherwise closed. # registration is otherwise closed.
if not is_first and not await get_setting(db, "allow_registration"): 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 return jsonify({"error": "registration is closed"}), 403
existing = await db.scalar(select(User).where(User.email == email)) existing = await db.scalar(select(User).where(User.email == email))
if existing is not None: if existing is not None:
@@ -137,10 +210,27 @@ async def register():
is_admin=is_first, is_admin=is_first,
) )
db.add(user) 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.commit()
await db.refresh(user) await db.refresh(user)
session[SESSION_KEY] = str(user.id) session[SESSION_KEY] = str(user.id)
session.permanent = True session.permanent = True
logger.info(
"account created email=%s admin=%s from=%s", email, is_first, client_address()
)
return jsonify(_serialize_user(user)), 201 return jsonify(_serialize_user(user)), 201
@@ -150,12 +240,28 @@ async def login():
email = (data.get("email") or "").strip().lower() email = (data.get("email") or "").strip().lower()
password = data.get("password") or "" password = data.get("password") or ""
wait = _sign_in_block(email)
if wait is not None:
return _throttled(wait)
async with session_scope() as db: async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email)) user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash or not verify_password(password, user.password_hash): if user is None or not user.password_hash:
# Hash anyway. Without this, "no such account" returns in microseconds
# while a wrong password takes bcrypt's deliberate ~100ms, and the
# 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 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[SESSION_KEY] = str(user.id)
session.permanent = True session.permanent = True
logger.info("sign-in ok email=%s from=%s", email, client_address())
return jsonify(_serialize_user(user)) return jsonify(_serialize_user(user))
@@ -210,11 +316,32 @@ async def device_login():
password = data.get("password") or "" password = data.get("password") or ""
if not email or not password: if not email or not password:
return jsonify({"error": "email and password are required"}), 400 return jsonify({"error": "email and password are required"}), 400
# Same budget as the web sign-in, and the SAME counters — this route hands out a
# long-lived bearer token, so leaving it unthrottled would just move the guessing
# here from /login.
wait = _sign_in_block(email)
if wait is not None:
return _throttled(wait)
async with session_scope() as db: async with session_scope() as db:
user = await db.scalar(select(User).where(User.email == email)) user = await db.scalar(select(User).where(User.email == email))
if user is None or not user.password_hash or not verify_password(password, user.password_hash): 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 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 "") 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() await db.commit()
return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201 return jsonify({"token": token, "device": _serialize_device(row), "user": _serialize_user(user)}), 201
+1
View File
@@ -48,3 +48,4 @@ class Config:
def secret_key_env(cls) -> str | None: def secret_key_env(cls) -> str | None:
"""Optional break-glass override for the cookie-signing secret.""" """Optional break-glass override for the cookie-signing secret."""
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None
-105
View File
@@ -1,105 +0,0 @@
from __future__ import annotations
from quart import Blueprint, g, jsonify
from sqlalchemy import func, select
from sqlalchemy.orm import aliased
from .auth import login_required
from .db import session_scope
from .models.label import Label, NoteLabel
from .models.note import Note
from .models.note_link import NoteLink
bp = Blueprint("graph", __name__, url_prefix="/api/graph")
@bp.get("")
@login_required
async def get_graph():
"""Spatial view of the owner's non-trashed notes.
Nodes are two kinds:
- notes (kind="note") — every non-trashed note; each carries its first
label's color for tinting.
- labels (kind="label", id "label:<uuid>") — every label actually attached
to a live note, acting as a clustering HUB so tagged notes gravitate
together even without wiki-links between them.
Edges are two kinds:
- wiki-links (kind="link") — resolved [[links]] (note_links.target_norm
matched to a note's normalized display_title).
- membership (kind="label") — each note → each of its label hubs.
The frontend toggles labels + unlinked notes; the graph is a light auxiliary
lens, not a focal surface.
"""
source = aliased(Note)
target = aliased(Note)
edge_stmt = (
select(source.id, target.id)
.select_from(NoteLink)
.join(source, source.id == NoteLink.source_id)
.join(target, func.lower(func.trim(target.display_title)) == NoteLink.target_norm)
.where(
source.owner_id == g.user_id,
source.deleted_at.is_(None),
target.owner_id == g.user_id,
target.deleted_at.is_(None),
source.id != target.id,
)
)
async with session_scope() as db:
rows = (await db.execute(edge_stmt)).all()
edges = []
seen: set = set()
for src_id, tgt_id in rows:
key = (src_id, tgt_id)
if key in seen:
continue
seen.add(key)
edges.append({"source": str(src_id), "target": str(tgt_id), "kind": "link"})
# Note ↔ label membership: one row per (note, label) for the owner's
# non-trashed notes. Drives both the note-color tint (first label by name)
# and the label-hub nodes + membership edges.
label_rows = (
await db.execute(
select(NoteLabel.note_id, Label.id, Label.name, Label.color)
.join(Label, Label.id == NoteLabel.label_id)
.join(Note, Note.id == NoteLabel.note_id)
.where(
Label.owner_id == g.user_id,
Note.owner_id == g.user_id,
Note.deleted_at.is_(None),
)
.order_by(Label.name)
)
).all()
first_color: dict = {}
label_nodes: dict = {}
for note_id, label_id, label_name, label_color in label_rows:
first_color.setdefault(note_id, label_color)
hub_id = f"label:{label_id}"
if hub_id not in label_nodes:
label_nodes[hub_id] = {
"id": hub_id,
"title": f"#{label_name}",
"color": label_color or "default",
"kind": "label",
"label_id": str(label_id),
}
edges.append({"source": str(note_id), "target": hub_id, "kind": "label"})
note_rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
).all()
nodes = [
{
"id": str(n.id),
"title": n.display_title or "Untitled",
"color": first_color.get(n.id, "default"),
"kind": "note",
}
for n in note_rows
]
nodes.extend(label_nodes.values())
return jsonify({"nodes": nodes, "edges": edges})
-1
View File
@@ -10,7 +10,6 @@ from . import ( # noqa: F401
note, note,
note_attachment, note_attachment,
note_item, note_item,
note_link,
note_link_preview, note_link_preview,
note_revision, note_revision,
saved_filter, saved_filter,
+5 -8
View File
@@ -38,16 +38,15 @@ class Note(Base):
owner_id: Mapped[uuid.UUID] = mapped_column( owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
) )
title: Mapped[str | None] = mapped_column(Text(), nullable=True) # The note's NAME: its first non-empty body line, else its first checklist item
# The note's display NAME: explicit title if set, else the first non-empty body # (see notes.derive_display_title). There is no title field to prefer — a note is
# line (see notes.derive_display_title). Persisted + normalized-matched so every # a body plus optional items, and this is simply the first thing written in it.
# note — even a body-only one — is nameable, searchable, graphable, and # Persisted so search results and export filenames have something to say, and so
# [[wiki-link]]-able without forcing the user to type a title. # the full-text vector can weight it above the rest of the body.
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default") color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
# 'text' (freeform body) or 'list' (a checklist of note_items). # '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. # Manual drag order (higher = earlier); 0 until the user reorders.
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0") position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
@@ -74,11 +73,9 @@ class Note(Base):
def serialize(self) -> dict: def serialize(self) -> dict:
return { return {
"id": str(self.id), "id": str(self.id),
"title": self.title,
"display_title": self.display_title, "display_title": self.display_title,
"body": self.body, "body": self.body,
"color": self.color, "color": self.color,
"kind": self.kind,
"position": self.position, "position": self.position,
"pinned": self.pinned, "pinned": self.pinned,
"archived": self.archived, "archived": self.archived,
+5 -1
View File
@@ -11,7 +11,11 @@ from . import Base
class NoteItem(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" __tablename__ = "note_items"
-23
View File
@@ -1,23 +0,0 @@
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class NoteLink(Base):
"""A [[wiki-link]] from a source note to a target title (normalized). Resolved
to a target note by matching target_norm against lower(trim(note.title))."""
__tablename__ = "note_links"
__table_args__ = (Index("ix_note_links_target", "target_norm"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
target_norm: Mapped[str] = mapped_column(Text(), nullable=False)
+3 -4
View File
@@ -11,9 +11,9 @@ from . import Base
class NoteRevision(Base): class NoteRevision(Base):
"""A point-in-time snapshot of a note's title+body, written on each edit that """A point-in-time snapshot of a note's body, written on each edit that changes
changes either — so an accidental overwrite can be viewed and restored. Only it — so an accidental overwrite can be viewed and restored. Only the body is
title+body are versioned in v1 (not items/attachments/labels).""" versioned (not items/attachments/labels)."""
__tablename__ = "note_revisions" __tablename__ = "note_revisions"
__table_args__ = (Index("ix_note_revisions_note_created", "note_id", "created_at"),) __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( note_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False 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="") 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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+1 -1
View File
@@ -13,7 +13,7 @@ from . import Base
class SavedFilter(Base): class SavedFilter(Base):
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one """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 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" __tablename__ = "saved_filters"
+50 -163
View File
@@ -1,7 +1,7 @@
"""Notes API (the `/api/notes` blueprint). """Notes API (the `/api/notes` blueprint).
The bulk of the shared logic lives in cohesive sibling modules — serialization The bulk of the shared logic lives in cohesive sibling modules — serialization
(`serialize`), wiki-links/tags (`links`), recurring reminders (`recurrence`), (`serialize`), #tags (`tags`), recurring reminders (`recurrence`),
small text/query helpers (`helpers`), and export/import (`import_export`). The small text/query helpers (`helpers`), and export/import (`import_export`). The
route handlers themselves stay here so blueprint registration is in one place, and route handlers themselves stay here so blueprint registration is in one place, and
`bp` is defined in `_bp` so every module can import it without a cycle. `bp` is defined in `_bp` so every module can import it without a cycle.
@@ -19,7 +19,7 @@ import zipfile
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from quart import Response, g, jsonify, request, send_file from quart import Response, g, jsonify, request, send_file
from sqlalchemy import case, func, literal_column, select from sqlalchemy import func, literal_column, select
from ..acl import visible_to_user from ..acl import visible_to_user
from ..auth import login_required from ..auth import login_required
@@ -32,19 +32,18 @@ from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem from ..models.note_item import NoteItem
from ..models.note_link import NoteLink
from ..models.note_link_preview import NoteLinkPreview from ..models.note_link_preview import NoteLinkPreview
from ..models.note_revision import NoteRevision from ..models.note_revision import NoteRevision
from ..responses import json_error, not_found, parse_uuid from ..responses import json_error, not_found, parse_uuid
from ..retention import purge_note from ..retention import purge_note
from ..settings import get_setting from ..settings import get_setting
from ..unfurl_queue import schedule as schedule_unfurls
from ..unfurl import UnfurlError, unfurl from ..unfurl import UnfurlError, unfurl
from ._bp import bp from ._bp import bp
from .helpers import ( from .helpers import (
ALLOWED_IMAGE_MIMES, ALLOWED_IMAGE_MIMES,
VALID_FILTERS, VALID_FILTERS,
_attachment_ext, _attachment_ext,
_escape_like,
_get_owned, _get_owned,
_header_filename, _header_filename,
_safe_filename, _safe_filename,
@@ -65,13 +64,9 @@ from .import_export import (
_read_import_specs, _read_import_specs,
_usec_to_dt, _usec_to_dt,
) )
from .links import ( from .tags import (
_reconcile_tags, _reconcile_tags,
_rename_inbound_links,
_rewrite_links,
parse_link_titles,
parse_tags, parse_tags,
rewrite_link_title,
) )
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes from .serialize import _items_for_notes, _labels_for_notes, _serialize_note, _serialize_notes
@@ -82,16 +77,11 @@ __all__ = [
"is_empty_note", "is_empty_note",
"parse_list_items", "parse_list_items",
"parse_tags", "parse_tags",
"parse_link_titles",
"rewrite_link_title",
"normalize_color", "normalize_color",
"normalize_recurrence", "normalize_recurrence",
"next_occurrence", "next_occurrence",
"_reconcile_tags", "_reconcile_tags",
"_rename_inbound_links",
"_rewrite_links",
"_serialize_notes", "_serialize_notes",
"_escape_like",
"_safe_filename", "_safe_filename",
"_attachment_ext", "_attachment_ext",
"_header_filename", "_header_filename",
@@ -112,7 +102,6 @@ async def list_notes():
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them. # saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
label_params = request.args.getlist("label") label_params = request.args.getlist("label")
color = request.args.get("color") color = request.args.get("color")
kind = request.args.get("kind")
has_reminder = coerce_bool(request.args.get("has_reminder")) has_reminder = coerce_bool(request.args.get("has_reminder"))
has_attachment = coerce_bool(request.args.get("has_attachment")) has_attachment = coerce_bool(request.args.get("has_attachment"))
query_text = (request.args.get("q") or "").strip() query_text = (request.args.get("q") or "").strip()
@@ -136,10 +125,6 @@ async def list_notes():
if color not in NOTE_COLORS: if color not in NOTE_COLORS:
return json_error("invalid color", 400) return json_error("invalid color", 400)
stmt = stmt.where(Note.color == color) 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: if has_reminder:
stmt = stmt.where(Note.remind_at.is_not(None)) stmt = stmt.where(Note.remind_at.is_not(None))
if has_attachment: if has_attachment:
@@ -155,8 +140,10 @@ async def list_notes():
return json_error("invalid created_before", 400) return json_error("invalid created_before", 400)
stmt = stmt.where(Note.created_at < before_dt) stmt = stmt.where(Note.created_at < before_dt)
if query_text: if query_text:
# Full-text match over title+body (generated tsvector, migration 0005), # Full-text match over the note's name + body (generated tsvector,
# ranked — so the facet bar's text box searches, not just filters. # 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) tsquery = func.websearch_to_tsquery("english", query_text)
search_col = literal_column("notes.search_vector") search_col = literal_column("notes.search_vector")
stmt = stmt.where(search_col.op("@@")(tsquery)).order_by( stmt = stmt.where(search_col.op("@@")(tsquery)).order_by(
@@ -170,30 +157,6 @@ async def list_notes():
return jsonify({"notes": await _serialize_notes(db, 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") @bp.get("/reminders")
@login_required @login_required
async def list_reminders(): async def list_reminders():
@@ -289,11 +252,9 @@ async def export_notes():
payload["notes"].append( payload["notes"].append(
{ {
"id": str(n.id), "id": str(n.id),
"title": n.title,
"display_title": n.display_title, "display_title": n.display_title,
"body": n.body, "body": n.body,
"color": n.color, "color": n.color,
"kind": n.kind,
"pinned": n.pinned, "pinned": n.pinned,
"archived": n.archived, "archived": n.archived,
"remind_at": n.remind_at.isoformat() if n.remind_at else None, "remind_at": n.remind_at.isoformat() if n.remind_at else None,
@@ -380,9 +341,13 @@ async def import_notes():
@bp.get("/titles") @bp.get("/titles")
@login_required @login_required
async def list_titles(): async def list_titles():
# Owner's non-trashed notes, keyed by their display NAME (explicit title or """Owner's non-trashed notes, keyed by their display NAME.
# first body line) — the index the frontend uses to resolve + autocomplete
# [[wiki-links]]. Every note has a name now, so body-only notes are linkable too. Survived the removal of [[wiki-links]] (note 2897) because it was serving two
different things, and only one of them was linking. This is what the command
palette lists so someone can jump to a note by name — which is recall, the thing
this app is actually for. The `[[` autocomplete that also read it is gone.
"""
async with session_scope() as db: async with session_scope() as db:
rows = ( rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None))) await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
@@ -392,73 +357,6 @@ async def list_titles():
) )
@bp.get("/link-search")
@login_required
async def link_search():
# Autocomplete source for [[wiki-links]]: match the query against a note's display
# NAME *or* its BODY, so you can link by recalling any phrase — not just the name.
# Substring ILIKE (good for partial-word typing, deterministic, fine at personal
# scale; the FTS index still powers the heavier /search). Name matches rank above
# body-only matches, and a name prefix above a mid-name substring. Empty q → recent.
q = (request.args.get("q") or "").strip()
async with session_scope() as db:
base = select(Note).where(
Note.owner_id == g.user_id, Note.deleted_at.is_(None), Note.display_title != ""
)
if not q:
stmt = base.order_by(Note.updated_at.desc()).limit(10)
else:
esc = _escape_like(q)
name_hit = Note.display_title.ilike(f"%{esc}%", escape="\\")
stmt = (
base.where(name_hit | Note.body.ilike(f"%{esc}%", escape="\\"))
.order_by(
case((name_hit, 0), else_=1),
case((Note.display_title.ilike(f"{esc}%", escape="\\"), 0), else_=1),
Note.updated_at.desc(),
)
.limit(10)
)
rows = (await db.scalars(stmt)).all()
return jsonify({"results": [{"id": str(n.id), "title": n.display_title} for n in rows]})
@bp.get("/<note_id>/backlinks")
@login_required
async def note_backlinks(note_id: str):
nid = parse_uuid(note_id)
if nid is None:
return not_found()
async with session_scope() as db:
note = await db.scalar(
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
if note is None:
return not_found()
if not note.display_title:
return jsonify({"backlinks": []})
norm = note.display_title.strip().lower()
sources = (
await db.scalars(
select(Note)
.join(NoteLink, NoteLink.source_id == Note.id)
.where(
NoteLink.target_norm == norm,
Note.owner_id == g.user_id,
Note.deleted_at.is_(None),
Note.id != nid,
)
)
).all()
seen: set = set()
out = []
for n in sources:
if n.id not in seen:
seen.add(n.id)
out.append({"id": str(n.id), "title": n.display_title})
return jsonify({"backlinks": out})
@bp.post("/reorder") @bp.post("/reorder")
@login_required @login_required
async def reorder_notes(): async def reorder_notes():
@@ -486,20 +384,32 @@ async def reorder_notes():
return jsonify({"ok": True}) return jsonify({"ok": True})
async def _name_for(db, note: Note, item_texts: list[str] | None = None) -> str:
"""The note's display name, consulting its checklist only when the body is silent.
`item_texts` short-circuits the query for callers that already hold the items
(create, import). Everyone else pays one narrow SELECT, and only when the body
produced nothing — which is the uncommon case.
"""
name = derive_display_title(note.body)
if name:
return name
if item_texts is not None:
return derive_display_title("", item_texts[0] if item_texts else None)
first = await db.scalar(
select(NoteItem.text).where(NoteItem.note_id == note.id).order_by(NoteItem.position).limit(1)
)
return derive_display_title("", first)
@bp.post("") @bp.post("")
@login_required @login_required
async def create_note(): async def create_note():
data = await request.get_json(silent=True) or {} 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 "" body = data.get("body") if isinstance(data.get("body"), str) else ""
kind = data.get("kind") if data.get("kind") in ("text", "list") else "text" # Items are accepted on ANY note — a checklist is something a note HAS.
# A checklist note's "content" is its items, not the body — so it's non-empty item_texts = parse_list_items(data.get("items"))
# when it has a title or at least one item (quick-add can create one in one shot). if is_empty_note(body, item_texts):
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):
return json_error("note is empty", 400) return json_error("note is empty", 400)
async with session_scope() as db: async with session_scope() as db:
# New notes go to the top of the manual order. # New notes go to the top of the manual order.
@@ -508,13 +418,10 @@ async def create_note():
Note.owner_id == g.user_id, Note.deleted_at.is_(None) Note.owner_id == g.user_id, Note.deleted_at.is_(None)
) )
) )
clean_title = title.strip() or None
note = Note( note = Note(
owner_id=g.user_id, owner_id=g.user_id,
title=clean_title, display_title=derive_display_title(body, item_texts[0] if item_texts else None),
display_title=derive_display_title(clean_title, body),
body=body, body=body,
kind=kind,
color=normalize_color(data.get("color")), color=normalize_color(data.get("color")),
position=int(max_pos) + 1, position=int(max_pos) + 1,
) )
@@ -522,10 +429,12 @@ async def create_note():
await db.flush() # assign note.id before writing items/links await db.flush() # assign note.id before writing items/links
for pos, text in enumerate(item_texts): for pos, text in enumerate(item_texts):
db.add(NoteItem(note_id=note.id, text=text, position=pos)) db.add(NoteItem(note_id=note.id, text=text, position=pos))
await _rewrite_links(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
# 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 return jsonify(await _serialize_note(db, note)), 201
@@ -552,18 +461,11 @@ async def update_note(note_id: str):
note = await _get_owned(db, note_id) note = await _get_owned(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
old_display = note.display_title
old_title = note.title
old_body = note.body 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): if "body" in data and isinstance(data["body"], str):
note.body = data["body"] note.body = data["body"]
if "color" in data: if "color" in data:
note.color = normalize_color(data["color"]) note.color = normalize_color(data["color"])
if "kind" in data and data["kind"] in ("text", "list"):
note.kind = data["kind"]
if "pinned" in data: if "pinned" in data:
note.pinned = bool(data["pinned"]) note.pinned = bool(data["pinned"])
if "archived" in data: if "archived" in data:
@@ -580,31 +482,22 @@ async def update_note(note_id: str):
note.remind_at = remind_dt note.remind_at = remind_dt
if "recurrence" in data: if "recurrence" in data:
note.recurrence = normalize_recurrence(data["recurrence"]) 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: if "body" in data:
await _rewrite_links(db, note) note.display_title = await _name_for(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
# The display NAME changing — via an explicit title OR the first body line — # Version history: snapshot the PRE-edit body whenever it changed.
# repoints inbound [[Old Name]] references so backlinks survive (skip pure if note.body != old_body:
# case/whitespace changes, which still resolve). db.add(NoteRevision(note_id=note.id, body=old_body))
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
# 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))
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
if note.body != old_body:
schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note))
def _serialize_revision(rev: NoteRevision) -> dict: def _serialize_revision(rev: NoteRevision) -> dict:
return { return {
"id": str(rev.id), "id": str(rev.id),
"title": rev.title,
"body": rev.body, "body": rev.body,
"created_at": iso(rev.created_at), "created_at": iso(rev.created_at),
} }
@@ -641,20 +534,14 @@ 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)) rev = await db.scalar(select(NoteRevision).where(NoteRevision.id == rid, NoteRevision.note_id == note.id))
if rev is None: if rev is None:
return not_found() 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 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 # Snapshot the CURRENT state first, so restoring is itself undoable, then apply
# the revision — with the same title/body ripple as a normal edit. # the revision — with the same body ripple as a normal edit.
old_display = note.display_title db.add(NoteRevision(note_id=note.id, body=note.body))
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
note.title = rev.title
note.body = rev.body note.body = rev.body
note.display_title = derive_display_title(note.title, note.body) note.display_title = await _name_for(db, note)
await _rewrite_links(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note))
+18 -15
View File
@@ -19,22 +19,30 @@ VALID_FILTERS = {"active", "archived", "trash"}
DISPLAY_TITLE_CAP = 200 DISPLAY_TITLE_CAP = 200
def derive_display_title(title: str | None, body: str | None) -> str: def derive_display_title(body: str | None, first_item: str | None = None) -> str:
"""The note's display NAME: the explicit title if set, else the first non-empty """The note's display NAME: the first non-empty line of the body, else the first
line of the body (trimmed, length-capped). Persisted as notes.display_title so a checklist item's text (both trimmed and length-capped).
body-only note is still nameable/searchable/linkable — the user never has to type
a title. Deterministic (literal first line, no AI).""" There is no explicit title to prefer any more (M13 step 3) — a note is a body plus
if title and title.strip(): optional items, and its name is simply the first thing written in it. Persisted as
return title.strip()[:DISPLAY_TITLE_CAP] 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(): for line in (body or "").splitlines():
stripped = line.strip() stripped = line.strip()
if stripped: if stripped:
return stripped[:DISPLAY_TITLE_CAP] return stripped[:DISPLAY_TITLE_CAP]
return "" return (first_item or "").strip()[:DISPLAY_TITLE_CAP]
def is_empty_note(title: str | None, body: str | None) -> bool: def is_empty_note(body: str | None, items: list | None = None) -> bool:
return not (title or "").strip() and not (body or "").strip() """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]: def parse_list_items(raw: object) -> list[str]:
@@ -74,11 +82,6 @@ async def _get_owned(db, note_id: str) -> Note | None:
) )
def _escape_like(s: str) -> str:
"""Escape LIKE wildcards so user input matches literally (escape char = \\)."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _slugify(text: str) -> str: def _slugify(text: str) -> str:
"""A filesystem-safe slug from a note's display name (for the .md filename).""" """A filesystem-safe slug from a note's display name (for the .md filename)."""
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower()) s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
+33 -27
View File
@@ -28,7 +28,7 @@ from .helpers import (
derive_display_title, derive_display_title,
is_empty_note, is_empty_note,
) )
from .links import _find_or_create_label, _reconcile_tags, _rewrite_links from .tags import _find_or_create_label, _reconcile_tags
from .recurrence import normalize_recurrence from .recurrence import normalize_recurrence
@@ -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. """One note as a human-readable Markdown file with a small frontmatter block.
The authoritative machine format is notes.json; this is for reading/portability.""" The authoritative machine format is notes.json; this is for reading/portability."""
fm = ["---"] fm = ["---"]
if note.title:
fm.append(f"title: {note.title}")
fm.append(f"display_name: {note.display_title}") fm.append(f"display_name: {note.display_title}")
if labels: if labels:
fm.append("labels: [" + ", ".join(lb["name"] for lb in 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(f"updated: {note.updated_at.isoformat() if note.updated_at else ''}")
fm.append("---") fm.append("---")
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: for it in items:
fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}") fm.append(f"- [{'x' if it['checked'] else ' '}] {it['text']}")
else:
fm.append(note.body)
return "\n".join(fm) + "\n" return "\n".join(fm) + "\n"
@@ -100,7 +102,6 @@ def _native_spec(n: dict) -> dict:
return { return {
"title": n.get("title"), "title": n.get("title"),
"body": n.get("body") or "", "body": n.get("body") or "",
"kind": n.get("kind"),
"color": n.get("color"), "color": n.get("color"),
"pinned": bool(n.get("pinned")), "pinned": bool(n.get("pinned")),
"archived": bool(n.get("archived")), "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 """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.""" 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 [] list_content = kn.get("listContent") if isinstance(kn.get("listContent"), list) else []
is_list = bool(list_content) # Keep's own notes are one or the other, but its text was being DISCARDED whenever
body = kn.get("textContent") or "" if not is_list else "" # 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 — # Keep stores link annotations (e.g. shared URLs) separately from the text —
# fold any URLs into the body so the content survives the move. # fold any URLs into the body so the content survives the move.
urls = [ urls = [
@@ -155,7 +158,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
return { return {
"title": kn.get("title"), "title": kn.get("title"),
"body": body, "body": body,
"kind": "list" if is_list else "text",
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"), "color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
"pinned": bool(kn.get("isPinned")), "pinned": bool(kn.get("isPinned")),
"archived": bool(kn.get("isArchived")), "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 db, owner_id, spec: dict, zf: zipfile.ZipFile, position: int, budget: _ImportBudget
) -> bool: ) -> bool:
"""Insert one imported note plus its items/labels/attachments, reusing the same """Insert one imported note plus its items/labels/attachments, reusing the same
display-title derivation + tag/link reconciliation as create_note. Returns False name derivation + tag reconciliation as create_note. Returns False (nothing
(nothing written) when the spec is empty.""" written) when the spec is empty."""
title = (spec.get("title") or "").strip() or None
body = spec.get("body") or "" 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 [] items = spec.get("items") or []
if kind == "list": item_texts = [t for t in ((it.get("text") or "").strip() for it in items) if t]
if not (title or any((it.get("text") or "").strip() for it in items)): if is_empty_note(body, item_texts):
return False
elif is_empty_note(title, body):
return False return False
note = Note( note = Note(
owner_id=owner_id, owner_id=owner_id,
title=title, display_title=derive_display_title(body, item_texts[0] if item_texts else None),
display_title=derive_display_title(title, body),
body=body, body=body,
kind=kind,
color=normalize_color(spec.get("color")), color=normalize_color(spec.get("color")),
pinned=bool(spec.get("pinned")), pinned=bool(spec.get("pinned")),
archived=bool(spec.get("archived")), archived=bool(spec.get("archived")),
@@ -310,11 +318,10 @@ async def _create_imported_note(
db.add(note) db.add(note)
await db.flush() # assign note.id before items/labels/attachments/links await db.flush() # assign note.id before items/labels/attachments/links
if kind == "list": for pos, it in enumerate(items):
for pos, it in enumerate(items): text = (it.get("text") or "").strip()
text = (it.get("text") or "").strip() if text:
if text: db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
# Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the # Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the
# body are handled by _reconcile_tags below, same as a normal create. # body are handled by _reconcile_tags below, same as a normal create.
@@ -333,6 +340,5 @@ async def _create_imported_note(
if isinstance(att, dict): if isinstance(att, dict):
_import_attachment(db, note, zf, att, budget) _import_attachment(db, note, zf, att, budget)
await _rewrite_links(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
return True return True
@@ -1,17 +1,20 @@
"""[[wiki-links]] and #tags — parsing note bodies and keeping the derived """#tags — parsing note bodies and keeping the derived tag-sourced note_labels rows
note_links / tag-sourced note_labels rows in sync with the text. Manual (picker) in sync with the text. Manual (picker) labels are NOT touched here (see the labeling
labels are NOT touched here (see the labeling module).""" module).
Was `links.py`, and also owned `[[wiki-links]]` until they were removed (note 2897):
this app is an intermediary surface for capture and recall, and a linking system is
organization, which is not what it is for. A file called links.py holding no links
would have been exactly the kind of drift that removal was meant to end.
"""
from __future__ import annotations from __future__ import annotations
import re import re
from sqlalchemy import delete, func, select from sqlalchemy import func, select
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_link import NoteLink
_LINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]")
# A #tag: `#` at the start of the body or after whitespace, then a word char and # A #tag: `#` at the start of the body or after whitespace, then a word char and
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by # word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
@@ -37,25 +40,6 @@ def parse_tags(body: str | None) -> list[str]:
return out return out
def parse_link_titles(body: str | None) -> list[str]:
"""Extract distinct normalized [[wiki-link]] titles from a note body."""
if not body:
return []
out: list[str] = []
for match in _LINK_RE.finditer(body):
norm = match.group(1).strip().lower()
if norm and norm not in out:
out.append(norm)
return out
async def _rewrite_links(db, note: Note) -> None:
"""Replace a note's outgoing wiki-links from its current body."""
await db.execute(delete(NoteLink).where(NoteLink.source_id == note.id))
for norm in parse_link_titles(note.body):
db.add(NoteLink(source_id=note.id, target_norm=norm))
async def _find_or_create_label(db, owner_id, name: str): async def _find_or_create_label(db, owner_id, name: str):
"""Owner's label id for `name` (case-insensitive match), creating it if absent.""" """Owner's label id for `name` (case-insensitive match), creating it if absent."""
existing = await db.scalar( existing = await db.scalar(
@@ -89,38 +73,3 @@ async def _reconcile_tags(db, note: Note) -> None:
if lid not in attached_ids: if lid not in attached_ids:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True)) db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid) attached_ids.add(lid)
def rewrite_link_title(body: str | None, old_norm: str, new_title: str) -> str:
"""Repoint every [[token]] whose normalized form == old_norm to [[new_title]]."""
if not body:
return body or ""
def _sub(match: re.Match) -> str:
return f"[[{new_title}]]" if match.group(1).strip().lower() == old_norm else match.group(0)
return _LINK_RE.sub(_sub, body)
async def _rename_inbound_links(db, renamed: Note, old_title: str, new_title: str) -> None:
"""Rewrite [[old title]] references (and their link rows) in every note that
links to the renamed note, so its backlinks survive the title change."""
old_norm = old_title.strip().lower()
sources = (
await db.scalars(
select(Note)
.join(NoteLink, NoteLink.source_id == Note.id)
.where(
NoteLink.target_norm == old_norm,
Note.owner_id == renamed.owner_id,
Note.deleted_at.is_(None),
)
)
).all()
seen: set = set()
for source in sources:
if source.id in seen:
continue
seen.add(source.id)
source.body = rewrite_link_title(source.body, old_norm, new_title)
await _rewrite_links(db, source)
+74
View File
@@ -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"
+134
View File
@@ -0,0 +1,134 @@
"""Throttling for the endpoints that a public deployment leaves exposed.
Only the credential endpoints are rate-limited: login, register, and the native
device-link exchange. Everything else already needs a session or a device token to
reach, so an attacker has to get through one of these three first.
## Why in-process is enough here, and where that stops being true
State lives in module-level dicts, so it is per-process. That is correct for how
this image actually serves — one hypercorn worker (see the Dockerfile, and the
same assumption the trash sweeper documents in app.py). If that ever gains
``--workers N``, each worker would keep its own counters and the effective limit
would multiply by N; the fix then is a shared store (the Postgres connection is
already there), not a bigger number here.
## Two keys, on purpose
Every attempt is counted against BOTH the account being tried and the address it
came from, and either one can refuse it:
- **The account** is the key that matters, and the key that cannot be forged. 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
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
registration is a row in the users table whether it succeeds or not.
"""
from __future__ import annotations
import time
from collections import deque
from collections.abc import Callable
from .settings import live
# 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
class SlidingWindow:
"""Counts events per key over a trailing window.
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: 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:
hits = deque()
# Insertion-ordered, so the first key is the least recently created.
if len(self._hits) >= MAX_BUCKETS:
self._hits.pop(next(iter(self._hits)), None)
self._hits[key] = hits
cutoff = now - self.window_s
while hits and hits[0] <= cutoff:
hits.popleft()
return hits
def retry_after(self, key: str, now: float | None = None) -> int | None:
"""Seconds until `key` may try again, or None while it is still under the
limit. Read-only — it does not count as an attempt."""
now = time.monotonic() if now is None else now
hits = self._prune(key, now)
if len(hits) < self.limit:
return None
# The window frees up when its OLDEST hit falls out of it.
return max(1, int(hits[0] + self.window_s - now) + 1)
def record(self, key: str, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
self._prune(key, now).append(now)
def forget(self, key: str) -> None:
"""Drop a key's history. Used after a successful sign-in, so someone who
fumbled a password twice and then got it right starts clean rather than
carrying those two for the next quarter of an hour."""
self._hits.pop(key, None)
def clear(self) -> None:
self._hits.clear()
def _minutes(key: str) -> Callable[[], float]:
return lambda: float(live(key)) * 60.0
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:
"""Drop every counter. For tests — nothing in the app calls this."""
for window in (sign_in_by_account, sign_in_by_address, register_by_address):
window.clear()
-3
View File
@@ -33,7 +33,6 @@ from .models.label import NoteLabel
from .models.note import Note from .models.note import Note
from .models.note_attachment import NoteAttachment from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem from .models.note_item import NoteItem
from .models.note_link import NoteLink
from .models.note_link_preview import NoteLinkPreview from .models.note_link_preview import NoteLinkPreview
from .models.note_revision import NoteRevision from .models.note_revision import NoteRevision
from .settings import get_setting from .settings import get_setting
@@ -88,10 +87,8 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id)) await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id)) await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id)) await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
await db.execute(sa_delete(NoteLink).where(NoteLink.source_id == note.id))
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id)) await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id)) await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
note.title = None
note.body = "" note.body = ""
note.display_title = "" note.display_title = ""
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was # `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
-1
View File
@@ -19,7 +19,6 @@ NAME_CAP = 100
_ALLOWED_PARAM_KEYS = { _ALLOWED_PARAM_KEYS = {
"q", "q",
"color", "color",
"kind",
"label", # matches the repeatable ?label= query param (stored as an array) "label", # matches the repeatable ?label= query param (stored as an array)
"has_reminder", "has_reminder",
"has_attachment", "has_attachment",
+17
View File
@@ -14,6 +14,23 @@ def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], bcrypt.gensalt()).decode("utf-8") return bcrypt.hashpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], bcrypt.gensalt()).decode("utf-8")
# A real bcrypt hash of a value nobody can present, used only to spend the time a
# verification would have. Computed once at import — generating it per call would
# cost a gensalt+hash on top of the checkpw and make the "no such user" path SLOWER
# than the real one, which is the same oracle pointing the other way.
_DUMMY_HASH = bcrypt.hashpw(secrets.token_bytes(32), bcrypt.gensalt())
def dummy_verify(password: str) -> None:
"""Burn one password verification against a throwaway hash.
For the sign-in path when the email has no account: it makes "no such user" cost
what "wrong password" costs, so response time stops answering the question of
which emails are registered here.
"""
bcrypt.checkpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], _DUMMY_HASH)
def verify_password(password: str, password_hash: str) -> bool: def verify_password(password: str, password_hash: str) -> bool:
try: try:
return bcrypt.checkpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], password_hash.encode("utf-8")) return bcrypt.checkpw(password.encode("utf-8")[:_MAX_BCRYPT_BYTES], password_hash.encode("utf-8"))
+133 -2
View File
@@ -19,6 +19,13 @@ class SettingDef:
label: str label: str
description: str description: str
group: 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 # The source of truth for every user-facing setting. Add a row here and it appears
@@ -32,7 +39,8 @@ REGISTRY: list[SettingDef] = [
"bool", "bool",
True, True,
"Allow new registrations", "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", "Access",
), ),
SettingDef( SettingDef(
@@ -69,6 +77,80 @@ REGISTRY: list[SettingDef] = [
"The server contacts the linked site; private/internal addresses are always blocked.", "The server contacts the linked site; private/internal addresses are always blocked.",
"Links", "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} _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, "label": d.label,
"description": d.description, "description": d.description,
"group": d.group, "group": d.group,
"minimum": d.minimum,
"maximum": d.maximum,
} }
) )
return result 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]: def validate_updates(updates: dict) -> tuple[dict, str | None]:
"""Coerce/validate a {key: value} dict against the registry. Returns """Coerce/validate a {key: value} dict against the registry. Returns
(clean_values, error_message). An unknown key or a bad int is rejected.""" (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}" return {}, f"unknown setting: {key}"
if defn.type == "int": if defn.type == "int":
try: try:
clean[key] = int(val) n = int(val)
except (ValueError, TypeError): except (ValueError, TypeError):
return {}, f"{defn.label} must be a whole number" 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": elif defn.type == "bool":
clean[key] = _coerce_bool(val) clean[key] = _coerce_bool(val)
else: else:
+5 -1
View File
@@ -6,7 +6,7 @@ from quart import Blueprint, current_app, jsonify, request
from .auth import require_admin from .auth import require_admin
from .db import session_scope 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") bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@@ -35,6 +35,10 @@ async def update_settings():
async with session_scope() as db: async with session_scope() as db:
await set_settings(db, clean) await set_settings(db, clean)
await db.commit() 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) result = await get_admin_settings(db)
# Apply the live-tunable knob without a restart (rule 25). # Apply the live-tunable knob without a restart (rule 25).
+52 -21
View File
@@ -28,8 +28,6 @@ from .models.note_item import NoteItem
from .models.note_revision import NoteRevision from .models.note_revision import NoteRevision
from .notes import ( from .notes import (
_reconcile_tags, _reconcile_tags,
_rename_inbound_links,
_rewrite_links,
_serialize_notes, _serialize_notes,
derive_display_title, derive_display_title,
normalize_color, normalize_color,
@@ -37,6 +35,7 @@ from .notes import (
) )
from .retention import purge_note from .retention import purge_note
from .serialize import serialize_label_sync from .serialize import serialize_label_sync
from .unfurl_queue import schedule as schedule_unfurls
bp = Blueprint("sync", __name__, url_prefix="/api/sync") bp = Blueprint("sync", __name__, url_prefix="/api/sync")
@@ -57,8 +56,14 @@ MAX_PUSH = 1000 # per-batch change cap
# Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise # Bump SYNC_PROTOCOL_VERSION for ANY wire change. Raise
# MIN_CLIENT_PROTOCOL_VERSION only for a genuinely BREAKING one: it is the switch # 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. # that hard-blocks older clients, so additive changes must leave it alone.
SYNC_PROTOCOL_VERSION = 1 # v2 (M13): `kind` and `title` both left the wire. Dropping a field a v1 client sends
MIN_CLIENT_PROTOCOL_VERSION = 1 # 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 # Named capabilities beyond the base protocol. An ADDITIVE change earns a name
# here rather than a min-version bump, so a newer client meeting an older server # here rather than a min-version bump, so a newer client meeting an older server
@@ -192,11 +197,8 @@ def client_wins(client_edited_at: datetime | None, server_edited_at: datetime |
def _assign_note_fields(note: Note, ch: dict) -> None: def _assign_note_fields(note: Note, ch: dict) -> None:
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is """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).""" 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.body = ch["body"] if isinstance(ch.get("body"), str) else ""
note.color = normalize_color(ch.get("color")) 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.pinned = bool(ch.get("pinned"))
note.archived = bool(ch.get("archived")) note.archived = bool(ch.get("archived"))
if ch.get("trashed"): if ch.get("trashed"):
@@ -210,13 +212,41 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
note.position = ch["position"] note.position = ch["position"]
async def _apply_note_items(db, note: Note, ch: dict) -> None: def _first_item_text(ch: dict) -> str:
"""Replace the note's checklist items with the client's (items sync inline).""" """The first non-blank checklist item in a pushed change, or "".
if note.kind != "list":
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id)) Read straight from the payload rather than the database because the note's name is
return 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") items = ch.get("items")
if not isinstance(items, list): 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 return
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id)) await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
for pos, it in enumerate(items): for pos, it in enumerate(items):
@@ -282,24 +312,25 @@ async def _apply_note(db, ch: dict) -> dict:
elif note.purged_at is not None: elif note.purged_at is not None:
note.purged_at = None # client re-created/edited → clear the tombstone note.purged_at = None # client re-created/edited → clear the tombstone
old_title, old_body, old_display = note.title, note.body, note.display_title old_body = note.body
_assign_note_fields(note, ch) _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: if edited_at is not None:
note.updated_at = edited_at note.updated_at = edited_at
# Non-destructive LWW: snapshot the overwritten server title+body into history. # Non-destructive LWW: snapshot the overwritten server body into history.
if not creating and (note.title != old_title or note.body != old_body): if not creating and note.body != old_body:
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body)) db.add(NoteRevision(note_id=note.id, body=old_body))
await db.flush() # assign note.id before items/labels/links await db.flush() # assign note.id before items/labels/links
await _apply_note_items(db, note, ch) await _apply_note_items(db, note, ch)
await _rewrite_links(db, note)
await _reconcile_tags(db, note) await _reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch) await _apply_note_manual_labels(db, note, ch)
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
await db.flush() await db.flush()
await db.refresh(note, ["sync_revision"]) 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 { return {
"id": str(nid), "id": str(nid),
"entity": "note", "entity": "note",
+127
View File
@@ -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)
+416
View File
@@ -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"
+61 -69
View File
@@ -5,9 +5,9 @@ import pytest
from thoughtsync.app import create_app from thoughtsync.app import create_app
from thoughtsync.common import coerce_bool, parse_dt from thoughtsync.common import coerce_bool, parse_dt
from thoughtsync.models.note import NOTE_COLORS, Note from thoughtsync.models.note import NOTE_COLORS, Note
from thoughtsync.unfurl_queue import detect_urls
from thoughtsync.notes import ( from thoughtsync.notes import (
_attachment_ext, _attachment_ext,
_escape_like,
_header_filename, _header_filename,
_keep_spec, _keep_spec,
_native_spec, _native_spec,
@@ -19,10 +19,8 @@ from thoughtsync.notes import (
next_occurrence, next_occurrence,
normalize_color, normalize_color,
normalize_recurrence, normalize_recurrence,
parse_link_titles,
parse_list_items, parse_list_items,
parse_tags, parse_tags,
rewrite_link_title,
) )
@@ -39,9 +37,9 @@ def test_all_note_routes_registered(app):
expected = { expected = {
f"notes.{name}" f"notes.{name}"
for name in ( 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", "snooze_reminder", "export_notes", "import_notes", "list_titles",
"link_search", "note_backlinks", "reorder_notes", "create_note", "reorder_notes", "create_note",
"get_note", "update_note", "list_revisions", "restore_revision", "get_note", "update_note", "list_revisions", "restore_revision",
"set_note_labels", "add_item", "update_item", "delete_item", "set_note_labels", "add_item", "update_item", "delete_item",
"reorder_items", "upload_attachment", "get_attachment", "reorder_items", "upload_attachment", "get_attachment",
@@ -54,9 +52,10 @@ def test_all_note_routes_registered(app):
def test_is_empty_note(): def test_is_empty_note():
assert is_empty_note(None, None) assert is_empty_note(None, None)
assert is_empty_note("", " ") assert is_empty_note(" ", [])
assert not is_empty_note("title", "") assert not is_empty_note("body")
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(): def test_normalize_color():
@@ -72,9 +71,9 @@ def test_palette_has_core_colors():
def test_serialize_shape(): 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() 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["body"] == "b"
assert s["color"] == "blue" assert s["color"] == "blue"
assert s["pinned"] is True assert s["pinned"] is True
@@ -118,51 +117,41 @@ async def test_reorder_requires_auth(app):
assert resp.status_code == 401 assert resp.status_code == 401
def test_parse_link_titles(): # [[wiki-links]] are gone entirely (note 2897), and with them backlinks, the graph,
titles = parse_link_titles("see [[Alpha]] and [[ beta ]] and [[Alpha]] again") # the name index and the `[[` autocomplete. So are the two helpers that used to keep
assert titles == ["alpha", "beta"] # links alive across a rename, and the id-binding that briefly replaced them. Nothing
# here asserts their absence — `test_all_note_routes_registered` below is what would
# notice a route coming back, and the removal is one commit rather than a fossil.
def test_parse_link_titles_empty(): def test_derive_display_title_is_the_first_body_line():
assert parse_link_titles(None) == [] assert derive_display_title("first line\nsecond line") == "first line"
assert parse_link_titles("no links here") == [] assert derive_display_title(" spaced first \nnext") == "spaced first"
def test_rewrite_link_title():
body = "see [[Alpha]] and [[ alpha ]] and [[Beta]]"
assert rewrite_link_title(body, "alpha", "Gamma") == "see [[Gamma]] and [[Gamma]] and [[Beta]]"
def test_rewrite_link_title_noop():
assert rewrite_link_title("", "alpha", "Gamma") == ""
assert rewrite_link_title(None, "alpha", "Gamma") == ""
assert rewrite_link_title("no links here", "alpha", "Gamma") == "no links here"
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"
# leading blank/whitespace lines are skipped to the first line with content # leading blank/whitespace lines are skipped to the first line with content
assert derive_display_title(None, "\n \nreal line\nmore") == "real line" assert derive_display_title("\n \nreal line\nmore") == "real line"
# a whitespace-only title falls through to the body
assert derive_display_title(" ", "body wins") == "body wins"
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(): def test_derive_display_title_empty():
assert derive_display_title(None, None) == "" assert derive_display_title(None) == ""
assert derive_display_title("", "") == "" assert derive_display_title("") == ""
assert derive_display_title(" ", " \n ") == "" assert derive_display_title(" \n ", None) == ""
assert derive_display_title(" \n ", " ") == ""
def test_derive_display_title_caps_length(): def test_derive_display_title_caps_length():
long = "x" * 300 long = "x" * 300
assert derive_display_title(None, long) == "x" * 200 assert derive_display_title(long) == "x" * 200
assert derive_display_title(long, "body") == "x" * 200 # the item fallback is capped on the same rule
assert derive_display_title("", long) == "x" * 200
def test_parse_tags(): def test_parse_tags():
@@ -182,14 +171,6 @@ def test_parse_list_items():
assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"] assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"]
def test_escape_like():
# LIKE wildcards in user input must be neutralized so they match literally.
assert _escape_like("100%") == "100\\%"
assert _escape_like("a_b") == "a\\_b"
assert _escape_like("c:\\path") == "c:\\\\path"
assert _escape_like("plain") == "plain"
def test_parse_dt(): def test_parse_dt():
# A full ISO instant round-trips (used to validate the Timeline date range). # A full ISO instant round-trips (used to validate the Timeline date range).
d = parse_dt("2026-07-19T12:30:00+00:00") d = parse_dt("2026-07-19T12:30:00+00:00")
@@ -211,18 +192,6 @@ async def test_titles_requires_auth(app):
assert resp.status_code == 401 assert resp.status_code == 401
async def test_link_search_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/notes/link-search?q=hi")
assert resp.status_code == 401
async def test_graph_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/graph")
assert resp.status_code == 401
async def test_reminders_requires_auth(app): async def test_reminders_requires_auth(app):
client = app.test_client() client = app.test_client()
resp = await client.get("/api/notes/reminders") resp = await client.get("/api/notes/reminders")
@@ -354,9 +323,13 @@ def test_usec_to_dt():
assert _usec_to_dt(None) is None 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 = { kn = {
"title": "Groceries", "title": "Groceries",
"textContent": "for the weekend",
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}], "listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
"labels": [{"name": "shopping"}], "labels": [{"name": "shopping"}],
"color": "TEAL", "color": "TEAL",
@@ -367,7 +340,7 @@ def test_keep_spec_list_note():
"userEditedTimestampUsec": 1600000100000000, "userEditedTimestampUsec": 1600000100000000,
} }
spec = _keep_spec(kn, "Takeout/Keep") spec = _keep_spec(kn, "Takeout/Keep")
assert spec["kind"] == "list" assert spec["body"] == "for the weekend"
assert spec["color"] == "teal" assert spec["color"] == "teal"
assert spec["pinned"] is True assert spec["pinned"] is True
assert spec["archived"] is False assert spec["archived"] is False
@@ -385,7 +358,6 @@ def test_keep_spec_text_note_folds_annotation_urls_and_maps_color():
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}], "attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
} }
spec = _keep_spec(kn, "Takeout/Keep") spec = _keep_spec(kn, "Takeout/Keep")
assert spec["kind"] == "text"
assert "https://example.com" in spec["body"] assert "https://example.com" in spec["body"]
assert spec["color"] == "orange" assert spec["color"] == "orange"
# attachment path is resolved relative to the note JSON's folder # attachment path is resolved relative to the note JSON's folder
@@ -397,7 +369,6 @@ def test_native_spec_roundtrip_fields():
"title": "T", "title": "T",
"body": "b", "body": "b",
"color": "blue", "color": "blue",
"kind": "text",
"pinned": True, "pinned": True,
"archived": False, "archived": False,
"created_at": "2026-07-19T00:00:00+00:00", "created_at": "2026-07-19T00:00:00+00:00",
@@ -406,6 +377,8 @@ def test_native_spec_roundtrip_fields():
"attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}], "attachments": [{"file": "attachments/ab/img.png", "mime": "image/png"}],
} }
spec = _native_spec(n) 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["title"] == "T"
assert spec["body"] == "b" assert spec["body"] == "b"
assert spec["color"] == "blue" assert spec["color"] == "blue"
@@ -414,3 +387,22 @@ def test_native_spec_roundtrip_fields():
assert spec["created_at"].year == 2026 assert spec["created_at"].year == 2026
assert spec["labels"] == ["x"] assert spec["labels"] == ["x"]
assert spec["attachments"] == [{"file": "attachments/ab/img.png", "mime": "image/png"}] 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("") == []
+73
View File
@@ -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"
+145
View File
@@ -0,0 +1,145 @@
"""The credential throttle. DB-free, like the rest of this suite.
The window itself is exercised directly with an injected clock, so nothing here
sleeps: a 15-minute window tested in real time is a test nobody runs twice.
The routes are exercised only as far as they get WITHOUT a database — a throttled
request returns 429 before any session is opened, which is the whole point of
checking the limit before the password. The happy path can't be reached here and is
not pretended at.
"""
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()
yield
ratelimit.reset_all()
@pytest.fixture
def app():
return create_app()
def test_under_the_limit_is_not_blocked():
w = window(3, 60)
for i in range(3):
assert w.retry_after("k", now=i) is None
w.record("k", now=i)
assert w.retry_after("k", now=3) is not None
def test_window_slides_rather_than_resetting():
w = window(2, 60)
w.record("k", now=0)
w.record("k", now=30)
assert w.retry_after("k", now=31) is not None
# The 0s hit falls out at t=60, which frees exactly one slot — the 30s hit is
# still inside the window, so this is a slide and not a reset.
assert w.retry_after("k", now=61) is None
w.record("k", now=61)
assert w.retry_after("k", now=62) is not None
def test_retry_after_points_past_the_oldest_hit():
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
# under-reported — a client that waits exactly this long must not be refused
# again.
assert wait is not None
assert 70 <= wait <= 72
assert w.retry_after("k", now=40 + wait) is None
def test_keys_are_counted_separately():
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 = window(1, 60)
w.record("a", now=0)
w.record("b", now=0)
w.forget("a")
assert w.retry_after("a", now=1) is None
assert w.retry_after("b", now=1) is not None
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 = window(5, 60)
for i in range(50):
w.record(f"addr-{i}", now=i)
assert len(w._hits) <= 8
async def test_login_starts_refusing(app):
client = app.test_client()
body = {"email": "someone@example.com", "password": "wrong-password"}
# Pre-load the account's counter to its limit rather than posting that many
# times: every real attempt would need a database to reach the password check.
#
# On the REAL clock, not an injected one. The window is trailing, so hits stamped
# 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(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
assert resp.headers.get("Retry-After")
# The refusal says nothing about whether that account exists.
assert "someone@example.com" not in (await resp.get_data(as_text=True))
async def test_device_login_shares_the_account_counter(app):
client = app.test_client()
now = time.monotonic()
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",
json={"email": "someone@example.com", "password": "wrong-password"},
)
# Same budget as /login — otherwise guessing just moves to the route that hands
# out a long-lived bearer token.
assert resp.status_code == 429
async def test_register_is_throttled_by_address(app):
client = app.test_client()
now = time.monotonic()
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
+57
View File
@@ -0,0 +1,57 @@
"""Headers every response carries once this is reachable from the internet.
Asserted on /api/health because it is the one route that needs no database and no
session — the headers are set in an after_request hook, so any response proves the
hook, and this suite has no Postgres.
"""
import pytest
from thoughtsync.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_content_security_policy_locks_scripts_to_self(app):
resp = await app.test_client().get("/api/health")
csp = resp.headers["Content-Security-Policy"]
assert "script-src 'self'" in csp
# The two that matter most if anything ever reflects user text into the page.
assert "object-src 'none'" in csp
assert "frame-ancestors 'none'" in csp
# No blanket unsafe-inline for SCRIPT — style is the only place it's conceded.
assert "script-src 'self' 'unsafe-inline'" not in csp
async def test_link_preview_images_are_still_allowed(app):
resp = await app.test_client().get("/api/health")
csp = resp.headers["Content-Security-Policy"]
# A preview renders the og:image of an arbitrary host; both schemes, because a
# LAN install is served over http.
assert "img-src" in csp
assert "https:" in csp
assert "http:" in csp
async def test_sniffing_and_referrer_are_pinned(app):
resp = await app.test_client().get("/api/health")
assert resp.headers["X-Content-Type-Options"] == "nosniff"
assert resp.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
assert "camera=()" in resp.headers["Permissions-Policy"]
async def test_no_hsts_on_plain_http(app):
# A plain-HTTP LAN install must not be told to refuse the only scheme it serves.
resp = await app.test_client().get("/api/health")
assert "Strict-Transport-Security" not in resp.headers
async def test_hsts_when_a_proxy_terminated_tls(app):
resp = await app.test_client().get("/api/health", headers={"X-Forwarded-Proto": "https"})
hsts = resp.headers["Strict-Transport-Security"]
assert "max-age=" in hsts
# Scoped to this host: neither of these commits domains the app doesn't own.
assert "includeSubDomains" not in hsts
assert "preload" not in hsts