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