diff --git a/.env.example b/.env.example index 1a2ba62..400b54d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 575b1f0..1899bc3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 223732e..da578b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "thoughtsync-desktop" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "serde", diff --git a/README.md b/README.md index 7d31a90..b8f4489 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ Then open `http://: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) · `:` (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 diff --git a/alembic/versions/0023_note_link_target_id.py b/alembic/versions/0023_note_link_target_id.py new file mode 100644 index 0000000..4a21a9d --- /dev/null +++ b/alembic/versions/0023_note_link_target_id.py @@ -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") diff --git a/alembic/versions/0024_drop_note_links.py b/alembic/versions/0024_drop_note_links.py new file mode 100644 index 0000000..ab831ff --- /dev/null +++ b/alembic/versions/0024_drop_note_links.py @@ -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"]) diff --git a/alembic/versions/0025_drop_note_kind.py b/alembic/versions/0025_drop_note_kind.py new file mode 100644 index 0000000..08fd2d0 --- /dev/null +++ b/alembic/versions/0025_drop_note_kind.py @@ -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")) diff --git a/alembic/versions/0026_drop_note_title.py b/alembic/versions/0026_drop_note_title.py new file mode 100644 index 0000000..e00bdc5 --- /dev/null +++ b/alembic/versions/0026_drop_note_title.py @@ -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)") diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index f88d0f8..d5d35a1 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -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 }, ) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index 037310e..a76bf5f 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt index 721b275..4d86514 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt @@ -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) }, ) } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt index c500b84..7bdcd1d 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index 4fbb146..1dfc04f 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -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), + ) + } } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index 91f290d..ae962f0 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 9281fda..cbf5be3 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -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, ) } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteKind.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteKind.kt deleted file mode 100644 index 4f20da4..0000000 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteKind.kt +++ /dev/null @@ -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" diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 04e5f28..5b232dd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -10,11 +10,7 @@ New note - Note - List - Title Take a note… - One item per line Discard Save @@ -41,14 +37,12 @@ Open note Back to notes - Title + Add a checklist Note Add item Remove item Remove label Set a reminder - Make a checklist - Switch to a note More actions Pin Unpin diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 190642b..9adfc1a 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -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 { diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index de42191..55c5ddc 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -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, - /// 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 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 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, pub color: Option, - pub kind: Option, pub label: Option>, pub has_reminder: Option, pub has_attachment: Option, @@ -324,7 +317,6 @@ impl From for core_models::Facets { let NoteFacets { q, color, - kind, label, has_reminder, has_attachment, @@ -334,7 +326,6 @@ impl From for core_models::Facets { core_models::Facets { q, color, - kind, label, has_reminder, has_attachment, @@ -347,31 +338,18 @@ impl From 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, - /// 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>, } impl From 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 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) -> 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()); } } diff --git a/ci-requirements.md b/ci-requirements.md index f356059..c458076 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -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 diff --git a/core/src/local/derive.rs b/core/src/local/derive.rs index 376674f..2588df3 100644 --- a/core/src/local/derive.rs +++ b/core/src/local/derive.rs @@ -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 { - let bytes = body.as_bytes(); - let mut out: Vec = 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 { @@ -73,28 +49,6 @@ fn push_unique(out: &mut Vec, 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::::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()); } } diff --git a/core/src/local/models.rs b/core/src/local/models.rs index 94ac2fd..3b5b1d0 100644 --- a/core/src/local/models.rs +++ b/core/src/local/models.rs @@ -8,13 +8,12 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize)] pub struct Note { pub id: String, - pub title: Option, - /// 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, pub body: String, pub created_at: Option, } @@ -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, - #[serde(default)] pub items: Option>, } @@ -168,8 +156,6 @@ pub struct Facets { #[serde(default)] pub color: Option, #[serde(default)] - pub kind: Option, - #[serde(default)] pub label: Option>, #[serde(default)] pub has_reminder: Option, diff --git a/core/src/local/retention.rs b/core/src/local/retention.rs index d5772d8..0a8ca82 100644 --- a/core/src/local/retention.rs +++ b/core/src/local/retention.rs @@ -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"); diff --git a/core/src/local/schema.rs b/core/src/local/schema.rs index 7e2e9f3..8b5decc 100644 --- a/core/src/local/schema.rs +++ b/core/src/local/schema.rs @@ -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(()) } diff --git a/core/src/local/store.rs b/core/src/local/store.rs index 80fa92a..e267184 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -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 { - 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 rusqlite::Result { 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 = 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.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 rusqlite::Result rusqlite::Result> { } pub fn titles(conn: &Connection) -> rusqlite::Result> { - let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?; - let rows = stmt.query_map([], |r| { - let title: Option = 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 = { + let mut stmt = conn.prepare("SELECT id FROM notes WHERE trashed = 0")?; + let rows = stmt.query_map([], |r| r.get(0))?; + rows.collect::>>()? + }; + 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> { let pat = format!("%{}%", escape_like(q)); let ids: Vec = { 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::>>()? @@ -350,77 +348,20 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result> { ids.iter().map(|id| load_note(conn, id)).collect() } -pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result> { - let target: String = { - let (t, b): (Option, 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 = 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> { - 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 = 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 { 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 { - 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) = - 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> { 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 { - let (title, body): (Option, 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)?; diff --git a/core/src/sync/compat.rs b/core/src/sync/compat.rs index e9a5908..42104e1 100644 --- a/core/src/sync/compat.rs +++ b/core/src/sync/compat.rs @@ -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); } diff --git a/core/src/sync/pull.rs b/core/src/sync/pull.rs index 3b33c85..977618c 100644 --- a/core/src/sync/pull.rs +++ b/core/src/sync/pull.rs @@ -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, diff --git a/core/src/sync/push.rs b/core/src/sync/push.rs index bb2e563..831629e 100644 --- a/core/src/sync/push.rs +++ b/core/src/sync/push.rs @@ -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, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub pinned: Option, #[serde(skip_serializing_if = "Option::is_none")] pub archived: Option, @@ -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, 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, 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, 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 { 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 { // 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], ) diff --git a/core/src/sync/wire.rs b/core/src/sync/wire.rs index 92aedef..87eb9d9 100644 --- a/core/src/sync/wire.rs +++ b/core/src/sync/wire.rs @@ -24,13 +24,9 @@ pub struct ChangesPage { pub struct Note { pub id: String, #[serde(default)] - pub title: Option, - #[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() } diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6781e93..a43e60e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/desktop/src-tauri/src/commands/local.rs b/desktop/src-tauri/src/commands/local.rs index f294545..e2e4a29 100644 --- a/desktop/src-tauri/src/commands/local.rs +++ b/desktop/src-tauri/src/commands/local.rs @@ -67,12 +67,6 @@ pub fn notes_create(input: NoteCreateInput, db: State<'_, Db>) -> Result) -> Result { - 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 { let conn = db.0.lock().map_err(|e| e.to_string())?; @@ -196,24 +190,6 @@ pub fn notes_titles(db: State<'_, Db>) -> Result, String> { store::titles(&conn).map_err(|e| e.to_string()) } -#[tauri::command] -pub fn notes_search(q: String, db: State<'_, Db>) -> Result, 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, 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, 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, String> { let conn = db.0.lock().map_err(|e| e.to_string())?; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 77c075b..51dd52f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 52db579..08296e1 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -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", diff --git a/docker-compose.yml b/docker-compose.yml index 26ed5ac..1e0fd25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/public-hosting.md b/docs/public-hosting.md new file mode 100644 index 0000000..8dbc7c6 --- /dev/null +++ b/docs/public-hosting.md @@ -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://: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. diff --git a/docs/sync.md b/docs/sync.md index 9787f07..709c81f 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -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, diff --git a/frontend/index.html b/frontend/index.html index 5727c4e..7a7bb44 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,13 @@ - + + 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("notes_list", { query }), get: (id) => invoke("notes_get", { id }), create: (input) => invoke("notes_create", { input }), - createTitled: (title) => invoke("notes_create_titled", { title }), update: (id, changes) => invoke("notes_update", { id, changes }), completeReminder: (id) => invoke("notes_complete_reminder", { id }), snoozeReminder: (id, minutes) => invoke("notes_snooze_reminder", { id, minutes }), @@ -72,9 +71,6 @@ export const local: Repo = { restoreRevision: (id, revId) => invoke("notes_restore_revision", { id, revId }), reminders: () => invoke("notes_reminders"), titles: () => invoke("notes_titles"), - search: (q) => invoke("notes_search", { q }), - backlinks: (id) => invoke("notes_backlinks", { id }), - linkSearch: (q) => invoke("notes_link_search", { q }), }, savedFilters: { diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 9ea20ee..3573952 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -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 + Pick >; 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; get(id: string): Promise; create(input: NoteCreateInput): Promise; - createTitled(title: string): Promise; update(id: string, changes: NoteChanges): Promise; completeReminder(id: string): Promise; snoozeReminder(id: string, minutes: number): Promise; @@ -118,9 +111,6 @@ export interface NotesRepo { restoreRevision(id: string, revId: string): Promise; reminders(): Promise; titles(): Promise; - search(q: string): Promise; - backlinks(id: string): Promise; - linkSearch(q: string): Promise; } export interface SavedFiltersRepo { diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index d550ca8..12efa73 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -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(`/api/notes/${id}`), create: (input: NoteCreateInput) => api.post("/api/notes", input), - createTitled: (title) => api.post("/api/notes", { title, body: "" }), update: (id, changes: NoteChanges) => api.patch(`/api/notes/${id}`, changes), completeReminder: (id) => api.post(`/api/notes/${id}/reminder/complete`), snoozeReminder: (id, minutes) => api.post(`/api/notes/${id}/reminder/snooze`, { minutes }), @@ -102,10 +99,6 @@ export const rest: Repo = { restoreRevision: (id, revId) => api.post(`/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: { diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index fb3fd47..9db9c1b 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -1,6 +1,6 @@